diff --git a/SpendLearning/SpendLearning.xcodeproj/project.pbxproj b/SpendLearning/SpendLearning.xcodeproj/project.pbxproj index 22f25e7..62bed6d 100644 --- a/SpendLearning/SpendLearning.xcodeproj/project.pbxproj +++ b/SpendLearning/SpendLearning.xcodeproj/project.pbxproj @@ -33,6 +33,7 @@ isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( Source/Domain/Entities/AI/AIInsightItem.swift, + Source/Domain/Entities/AI/AIModelCreationError.swift, Source/Domain/Entities/AI/AIModelMetadata.swift, Source/Domain/Entities/AI/CategoryPredictionDataPoint.swift, Source/Domain/Entities/AI/CumulativePrediction.swift, diff --git a/SpendLearning/SpendLearning/Source/App/SceneDelegate.swift b/SpendLearning/SpendLearning/Source/App/SceneDelegate.swift index 1fc71d1..f787627 100644 --- a/SpendLearning/SpendLearning/Source/App/SceneDelegate.swift +++ b/SpendLearning/SpendLearning/Source/App/SceneDelegate.swift @@ -24,10 +24,10 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { private func makeTabBarController() -> UITabBarController { let container: ModelContainer do { - container = try ModelContainer(for: ExpenseModel.self, CategoryModel.self) + container = try ModelContainer(for: ExpenseModel.self, CategoryModel.self, AIModelModel.self) } catch { container = try! ModelContainer( - for: ExpenseModel.self, CategoryModel.self, + for: ExpenseModel.self, CategoryModel.self, AIModelModel.self, configurations: ModelConfiguration(isStoredInMemoryOnly: true) ) } @@ -36,7 +36,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { let categoryRepository = SwiftDataCategoryRepository(modelContext: modelContext) let categoryUseCase = CategoryUseCase(repository: categoryRepository) - let expenseRepository = MockExpenseRepository() + let expenseRepository = MockExpenseRepository() // let expenseRepository = SwiftDataExpenseRepository(modelContext: modelContext, categoryRepository: categoryRepository) let expenseUseCase = ExpenseUseCase(repository: expenseRepository) @@ -48,7 +48,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { selectedImage: UIImage(systemName: "house.fill") ) - let aiUseCase = AIUseCase(repository: MockAIRepository()) + let aiUseCase = AIUseCase(repository: SwiftDataAIRepository(modelContext: modelContext, expenseRepository: expenseRepository), expenseRepository: expenseRepository) let aiViewModel = AIViewModel(expenseUseCase: expenseUseCase, aiUseCase: aiUseCase) let aiViewController = UIHostingController(rootView: AIView(viewModel: aiViewModel)) aiViewController.tabBarItem = UITabBarItem( diff --git a/SpendLearning/SpendLearning/Source/Data/Models/AIModelModel.swift b/SpendLearning/SpendLearning/Source/Data/Models/AIModelModel.swift new file mode 100644 index 0000000..19274ad --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Data/Models/AIModelModel.swift @@ -0,0 +1,33 @@ +// +// AIModelModel.swift +// SpendLearning +// +// Created by 김성훈 on 7/14/26. +// + +import Foundation +import SwiftData + +@Model +final class AIModelModel { + + var id: String + var dataCount: Int + var accuracy: Float? + var createdAt: Date + var isSelected: Bool + + init( + id: String, + dataCount: Int, + accuracy: Float?, + createdAt: Date, + isSelected: Bool + ) { + self.id = id + self.dataCount = dataCount + self.accuracy = accuracy + self.createdAt = createdAt + self.isSelected = isSelected + } +} diff --git a/SpendLearning/SpendLearning/Source/Data/Repositories/MockAIRepository.swift b/SpendLearning/SpendLearning/Source/Data/Repositories/MockAIRepository.swift index 6c6d5dd..4fce474 100644 --- a/SpendLearning/SpendLearning/Source/Data/Repositories/MockAIRepository.swift +++ b/SpendLearning/SpendLearning/Source/Data/Repositories/MockAIRepository.swift @@ -10,9 +10,9 @@ import Foundation final class MockAIRepository: AIRepositoryProtocol { private let models: [AIModelMetadata] = [ - AIModelMetadata(id: "SP260714", dataCount: 1204, accuracy: 82, createdAt: Date()), - AIModelMetadata(id: "SP260614", dataCount: 980, accuracy: 76, createdAt: Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date()), - AIModelMetadata(id: "SP260514", dataCount: 750, accuracy: 71, createdAt: Calendar.current.date(byAdding: .month, value: -2, to: Date()) ?? Date()), + AIModelMetadata(id: "SPa1b2c3", dataCount: 1204, accuracy: nil, createdAt: Date()), + AIModelMetadata(id: "SPd4e5f6", dataCount: 980, accuracy: nil, createdAt: Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date()), + AIModelMetadata(id: "SPg7h8i9", dataCount: 750, accuracy: nil, createdAt: Calendar.current.date(byAdding: .month, value: -2, to: Date()) ?? Date()), ] func fetchModels() async -> [AIModelMetadata] { @@ -52,4 +52,11 @@ final class MockAIRepository: AIRepositoryProtocol { "의료/건강": 30000, ] } + + func createModel(expenses: [Expense]) async -> Result { + let model = AIModelMetadata(id: "SPa1b2c3", dataCount: expenses.count, accuracy: nil, createdAt: Date()) + return .success(model) + } + + func selectModel(id: String) async {} } diff --git a/SpendLearning/SpendLearning/Source/Data/Repositories/MockExpenseRepository.swift b/SpendLearning/SpendLearning/Source/Data/Repositories/MockExpenseRepository.swift index c1d4542..6f06fba 100644 --- a/SpendLearning/SpendLearning/Source/Data/Repositories/MockExpenseRepository.swift +++ b/SpendLearning/SpendLearning/Source/Data/Repositories/MockExpenseRepository.swift @@ -11,16 +11,9 @@ final class MockExpenseRepository: ExpenseRepositoryProtocol { private var expenses: [Expense] = { let calendar = Calendar.current - let year = calendar.component(.year, from: Date()) - let month = calendar.component(.month, from: Date()) - var components = DateComponents() - components.year = year - components.month = month - - func date(_ day: Int) -> Date { - components.day = day - return calendar.date(from: components)! - } + let now = Date() + let year = calendar.component(.year, from: now) + let month = calendar.component(.month, from: now) let food = Category(name: "식비", emoji: "🍚") let cafe = Category(name: "카페/간식", emoji: "☕️") @@ -28,24 +21,150 @@ final class MockExpenseRepository: ExpenseRepositoryProtocol { let shopping = Category(name: "쇼핑", emoji: "🛍️") let medical = Category(name: "의료/건강", emoji: "💊") - return [ - Expense(date: date(1), category: food, memo: "김밥천국", amount: 8500), - Expense(date: date(3), category: cafe, memo: "스타벅스", amount: 6000), - Expense(date: date(8), category: transport, memo: "택시", amount: 12000), - Expense(date: date(10), category: shopping, memo: "다이소", amount: 15000), - Expense(date: date(10), category: food, memo: "한식당", amount: 12000), - Expense(date: date(10), category: medical, memo: "약국", amount: 8500), - Expense(date: date(13), category: cafe, memo: "투썸플레이스", amount: 6200), - Expense(date: date(15), category: food, memo: "점심", amount: 9000), - Expense(date: date(15), category: cafe, memo: "스타벅스", amount: 6000), - Expense(date: date(15), category: transport, memo: "택시", amount: 12000), - Expense(date: date(18), category: shopping, memo: "올리브영", amount: 9800), - Expense(date: date(20), category: food, memo: "저녁", amount: 67000), - Expense(date: date(22), category: cafe, memo: "카페", amount: 15000), - Expense(date: date(25), category: shopping, memo: "쿠팡", amount: 120000), - Expense(date: date(28), category: transport, memo: "지하철", amount: 4500), - Expense(date: date(30), category: food, memo: "외식", amount: 32000), + // 특정 달의 특정 일 날짜 생성 + func date(monthOffset: Int, day: Int) -> Date { + let targetMonth = month + monthOffset + let targetYear = year + (targetMonth - 1) / 12 + var components = DateComponents() + components.year = targetYear + components.month = ((targetMonth - 1 + 12) % 12) + 1 + components.day = day + return calendar.date(from: components)! + } + + // 오늘 기준 N주 전, 특정 요일 날짜 생성 (weekday: 1=일, 2=월 ... 7=토) + func dateByWeekday(weeksAgo: Int, weekday: Int) -> Date { + let weekStart = calendar.date(byAdding: .weekOfYear, value: -weeksAgo, to: now)! + let currentWeekday = calendar.component(.weekday, from: weekStart) + let diff = weekday - currentWeekday + return calendar.date(byAdding: .day, value: diff, to: weekStart)! + } + + // MARK: - 이번 달 일반 지출 + let thisMonth: [Expense] = [ + Expense(date: date(monthOffset: 0, day: 1), category: food, memo: "김밥천국", amount: 8500), + Expense(date: date(monthOffset: 0, day: 3), category: cafe, memo: "스타벅스", amount: 6000), + Expense(date: date(monthOffset: 0, day: 8), category: transport, memo: "택시", amount: 12000), + Expense(date: date(monthOffset: 0, day: 10), category: shopping, memo: "다이소", amount: 15000), + Expense(date: date(monthOffset: 0, day: 10), category: food, memo: "한식당", amount: 12000), + Expense(date: date(monthOffset: 0, day: 10), category: medical, memo: "약국", amount: 8500), + ] + + // MARK: - 이상 지출 감지용 + // 최근 4주 카페 주 평균 약 6,000원 → 이번 주 20,000원으로 1.5배 초과 + let abnormalThisWeek: [Expense] = [ + Expense(date: dateByWeekday(weeksAgo: 0, weekday: 2), category: cafe, memo: "스타벅스", amount: 20000), + ] + let abnormalPast: [Expense] = [ + Expense(date: dateByWeekday(weeksAgo: 1, weekday: 2), category: cafe, memo: "스타벅스", amount: 6000), + Expense(date: dateByWeekday(weeksAgo: 2, weekday: 2), category: cafe, memo: "스타벅스", amount: 5500), + Expense(date: dateByWeekday(weeksAgo: 3, weekday: 2), category: cafe, memo: "스타벅스", amount: 6500), + Expense(date: dateByWeekday(weeksAgo: 4, weekday: 2), category: cafe, memo: "스타벅스", amount: 6000), + ] + + // MARK: - 지출 예고용 + // 최근 3주 연속 목요일(5)에 식비 지출 → 이번 주 목요일 예고 + let forecastPast: [Expense] = [ + Expense(date: dateByWeekday(weeksAgo: 1, weekday: 5), category: food, memo: "점심 정기모임", amount: 15000), + Expense(date: dateByWeekday(weeksAgo: 2, weekday: 5), category: food, memo: "점심 정기모임", amount: 14000), + Expense(date: dateByWeekday(weeksAgo: 3, weekday: 5), category: food, memo: "점심 정기모임", amount: 16000), ] + + // MARK: - 미기록 감지용 + // 최근 3주 연속 일요일(1)에 교통비 지출 → 이번 주 일요일 기록 없음 + let unrecordedPast: [Expense] = [ + Expense(date: dateByWeekday(weeksAgo: 1, weekday: 1), category: transport, memo: "일요일 택시", amount: 12000), + Expense(date: dateByWeekday(weeksAgo: 2, weekday: 1), category: transport, memo: "일요일 택시", amount: 11000), + Expense(date: dateByWeekday(weeksAgo: 3, weekday: 1), category: transport, memo: "일요일 택시", amount: 13000), + ] + + // MARK: - 과거 일반 지출 + let oneMonthAgo: [Expense] = [ + Expense(date: date(monthOffset: -1, day: 2), category: food, memo: "김밥천국", amount: 7500), + Expense(date: date(monthOffset: -1, day: 5), category: cafe, memo: "스타벅스", amount: 6000), + Expense(date: date(monthOffset: -1, day: 9), category: transport, memo: "택시", amount: 11000), + Expense(date: date(monthOffset: -1, day: 11), category: shopping, memo: "다이소", amount: 13000), + Expense(date: date(monthOffset: -1, day: 11), category: food, memo: "한식당", amount: 11000), + Expense(date: date(monthOffset: -1, day: 14), category: cafe, memo: "투썸플레이스", amount: 5800), + Expense(date: date(monthOffset: -1, day: 16), category: food, memo: "점심", amount: 8500), + Expense(date: date(monthOffset: -1, day: 18), category: medical, memo: "약국", amount: 7500), + Expense(date: date(monthOffset: -1, day: 20), category: transport, memo: "지하철", amount: 4500), + Expense(date: date(monthOffset: -1, day: 22), category: food, memo: "저녁", amount: 55000), + Expense(date: date(monthOffset: -1, day: 25), category: cafe, memo: "카페", amount: 14000), + Expense(date: date(monthOffset: -1, day: 27), category: shopping, memo: "쿠팡", amount: 98000), + Expense(date: date(monthOffset: -1, day: 29), category: food, memo: "외식", amount: 28000), + ] + + let twoMonthsAgo: [Expense] = [ + Expense(date: date(monthOffset: -2, day: 1), category: food, memo: "김밥천국", amount: 8000), + Expense(date: date(monthOffset: -2, day: 4), category: cafe, memo: "스타벅스", amount: 5500), + Expense(date: date(monthOffset: -2, day: 7), category: transport, memo: "택시", amount: 13000), + Expense(date: date(monthOffset: -2, day: 10), category: food, memo: "한식당", amount: 10500), + Expense(date: date(monthOffset: -2, day: 12), category: shopping, memo: "올리브영", amount: 22000), + Expense(date: date(monthOffset: -2, day: 15), category: cafe, memo: "투썸플레이스", amount: 6200), + Expense(date: date(monthOffset: -2, day: 17), category: food, memo: "점심", amount: 9500), + Expense(date: date(monthOffset: -2, day: 19), category: medical, memo: "병원", amount: 15000), + Expense(date: date(monthOffset: -2, day: 21), category: transport, memo: "지하철", amount: 4500), + Expense(date: date(monthOffset: -2, day: 23), category: food, memo: "저녁", amount: 48000), + Expense(date: date(monthOffset: -2, day: 26), category: cafe, memo: "카페", amount: 12000), + Expense(date: date(monthOffset: -2, day: 28), category: shopping, memo: "쿠팡", amount: 75000), + ] + + let threeMonthsAgo: [Expense] = [ + Expense(date: date(monthOffset: -3, day: 2), category: food, memo: "김밥천국", amount: 7000), + Expense(date: date(monthOffset: -3, day: 5), category: cafe, memo: "스타벅스", amount: 6000), + Expense(date: date(monthOffset: -3, day: 8), category: transport, memo: "택시", amount: 10000), + Expense(date: date(monthOffset: -3, day: 11), category: food, memo: "한식당", amount: 12000), + Expense(date: date(monthOffset: -3, day: 13), category: medical, memo: "약국", amount: 8000), + Expense(date: date(monthOffset: -3, day: 16), category: cafe, memo: "투썸플레이스", amount: 5500), + Expense(date: date(monthOffset: -3, day: 18), category: shopping, memo: "다이소", amount: 18000), + Expense(date: date(monthOffset: -3, day: 20), category: food, memo: "점심", amount: 8000), + Expense(date: date(monthOffset: -3, day: 22), category: transport, memo: "지하철", amount: 4500), + Expense(date: date(monthOffset: -3, day: 24), category: food, memo: "저녁", amount: 42000), + Expense(date: date(monthOffset: -3, day: 27), category: cafe, memo: "카페", amount: 11000), + Expense(date: date(monthOffset: -3, day: 29), category: shopping, memo: "쿠팡", amount: 65000), + ] + + let fourMonthsAgo: [Expense] = [ + Expense(date: date(monthOffset: -4, day: 1), category: food, memo: "김밥천국", amount: 8500), + Expense(date: date(monthOffset: -4, day: 4), category: cafe, memo: "스타벅스", amount: 5800), + Expense(date: date(monthOffset: -4, day: 7), category: transport, memo: "택시", amount: 12000), + Expense(date: date(monthOffset: -4, day: 9), category: food, memo: "한식당", amount: 11500), + Expense(date: date(monthOffset: -4, day: 12), category: shopping, memo: "올리브영", amount: 19000), + Expense(date: date(monthOffset: -4, day: 14), category: cafe, memo: "투썸플레이스", amount: 6000), + Expense(date: date(monthOffset: -4, day: 17), category: food, memo: "점심", amount: 9000), + Expense(date: date(monthOffset: -4, day: 19), category: medical, memo: "병원", amount: 12000), + Expense(date: date(monthOffset: -4, day: 21), category: transport, memo: "지하철", amount: 4500), + Expense(date: date(monthOffset: -4, day: 23), category: food, memo: "저녁", amount: 51000), + Expense(date: date(monthOffset: -4, day: 26), category: cafe, memo: "카페", amount: 13000), + Expense(date: date(monthOffset: -4, day: 28), category: shopping, memo: "쿠팡", amount: 88000), + ] + + let fiveMonthsAgo: [Expense] = [ + Expense(date: date(monthOffset: -5, day: 2), category: food, memo: "김밥천국", amount: 7500), + Expense(date: date(monthOffset: -5, day: 5), category: cafe, memo: "스타벅스", amount: 6200), + Expense(date: date(monthOffset: -5, day: 8), category: transport, memo: "택시", amount: 11500), + Expense(date: date(monthOffset: -5, day: 10), category: food, memo: "한식당", amount: 10000), + Expense(date: date(monthOffset: -5, day: 13), category: shopping, memo: "다이소", amount: 14000), + Expense(date: date(monthOffset: -5, day: 15), category: cafe, memo: "투썸플레이스", amount: 5500), + Expense(date: date(monthOffset: -5, day: 18), category: food, memo: "점심", amount: 8500), + Expense(date: date(monthOffset: -5, day: 20), category: medical, memo: "약국", amount: 9000), + Expense(date: date(monthOffset: -5, day: 22), category: transport, memo: "지하철", amount: 4500), + Expense(date: date(monthOffset: -5, day: 24), category: food, memo: "저녁", amount: 45000), + Expense(date: date(monthOffset: -5, day: 27), category: cafe, memo: "카페", amount: 12500), + Expense(date: date(monthOffset: -5, day: 29), category: shopping, memo: "쿠팡", amount: 72000), + ] + + return thisMonth + + abnormalThisWeek + + abnormalPast + + forecastPast + + unrecordedPast + + oneMonthAgo + + twoMonthsAgo + + threeMonthsAgo + + fourMonthsAgo + + fiveMonthsAgo }() func fetchExpenses(year: Int, month: Int) async -> [Expense] { @@ -57,6 +176,10 @@ final class MockExpenseRepository: ExpenseRepositoryProtocol { } } + func fetchAllExpenses() async -> [Expense] { + expenses + } + func deleteExpense(_ expense: Expense) async { expenses.removeAll { $0.id == expense.id } } diff --git a/SpendLearning/SpendLearning/Source/Data/Repositories/SwiftDataAIRepository.swift b/SpendLearning/SpendLearning/Source/Data/Repositories/SwiftDataAIRepository.swift new file mode 100644 index 0000000..8a6f137 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Data/Repositories/SwiftDataAIRepository.swift @@ -0,0 +1,115 @@ +// +// SwiftDataAIRepository.swift +// SpendLearning +// +// Created by 김성훈 on 7/14/26. +// + +import Foundation +import SwiftData + +final class SwiftDataAIRepository: AIRepositoryProtocol { + + private let modelContext: ModelContext + private let expenseRepository: ExpenseRepositoryProtocol + private let strategy = StatisticsPredictionStrategy() + + init( + modelContext: ModelContext, + expenseRepository: ExpenseRepositoryProtocol + ) { + self.modelContext = modelContext + self.expenseRepository = expenseRepository + } + + func fetchModels() async -> [AIModelMetadata] { + let descriptor = FetchDescriptor() + let models = (try? modelContext.fetch(descriptor)) ?? [] + return models + .sorted { + if $0.isSelected != $1.isSelected { return $0.isSelected } + return $0.createdAt > $1.createdAt + } + .map { toMetadata($0) } + } + + func fetchCurrentModel() async -> AIModelMetadata? { + let descriptor = FetchDescriptor() + let models = (try? modelContext.fetch(descriptor)) ?? [] + return models.first { $0.isSelected }.map { toMetadata($0) } + } + + func fetchInsights() async -> [AIInsightItem] { + guard hasSavedModel() else { return [] } + let expenses = await expenseRepository.fetchAllExpenses() + return strategy.predictInsights(expenses: expenses) + } + + func fetchDailyPredictions(year: Int, month: Int) async -> [Int: Int] { + guard hasSavedModel() else { return [:] } + let expenses = await expenseRepository.fetchAllExpenses() + return strategy.predictDaily(expenses: expenses, year: year, month: month) + } + + func fetchCategoryPredictions(year: Int, month: Int) async -> [String: Int] { + guard hasSavedModel() else { return [:] } + let expenses = await expenseRepository.fetchAllExpenses() + return strategy.predictCategory(expenses: expenses, year: year, month: month) + } + + func createModel(expenses: [Expense]) async -> Result { + let calendar = Calendar.current + let now = Date() + let currentYear = calendar.component(.year, from: now) + let currentMonth = calendar.component(.month, from: now) + + // 저번 달 소비가 1건 이상 있어야 생성 가능 + let lastMonthExpenses = expenses.filter { + let y = calendar.component(.year, from: $0.date) + let m = calendar.component(.month, from: $0.date) + return !(y == currentYear && m == currentMonth) + } + guard !lastMonthExpenses.isEmpty else { return .failure(.insufficientData) } + + // 기존 모델 선택 해제 + let descriptor = FetchDescriptor() + let existing = (try? modelContext.fetch(descriptor)) ?? [] + existing.forEach { $0.isSelected = false } + + let id = "SP\(UUID().uuidString.prefix(6).lowercased())" + let model = AIModelModel( + id: id, + dataCount: expenses.count, + accuracy: nil, + createdAt: now, + isSelected: true + ) + modelContext.insert(model) + try? modelContext.save() + + return .success(toMetadata(model)) + } + + func selectModel(id: String) async { + let descriptor = FetchDescriptor() + let models = (try? modelContext.fetch(descriptor)) ?? [] + models.forEach { $0.isSelected = ($0.id == id) } + try? modelContext.save() + } + + // MARK: - Private + + private func toMetadata(_ model: AIModelModel) -> AIModelMetadata { + AIModelMetadata( + id: model.id, + dataCount: model.dataCount, + accuracy: model.accuracy, + createdAt: model.createdAt + ) + } + + private func hasSavedModel() -> Bool { + let descriptor = FetchDescriptor() + return !((try? modelContext.fetch(descriptor)) ?? []).isEmpty + } +} diff --git a/SpendLearning/SpendLearning/Source/Data/Repositories/SwiftDataExpenseRepository.swift b/SpendLearning/SpendLearning/Source/Data/Repositories/SwiftDataExpenseRepository.swift index eddc69a..a27b84d 100644 --- a/SpendLearning/SpendLearning/Source/Data/Repositories/SwiftDataExpenseRepository.swift +++ b/SpendLearning/SpendLearning/Source/Data/Repositories/SwiftDataExpenseRepository.swift @@ -37,6 +37,13 @@ final class SwiftDataExpenseRepository: ExpenseRepositoryProtocol { return models.map { toExpense($0, categories: categories) } } + func fetchAllExpenses() async -> [Expense] { + let descriptor = FetchDescriptor() + let models = (try? modelContext.fetch(descriptor)) ?? [] + let categories = (try? await categoryRepository.fetchCategories()) ?? [] + return models.map { toExpense($0, categories: categories) } + } + func addExpense(_ expense: Expense) async { let model = toModel(expense) modelContext.insert(model) diff --git a/SpendLearning/SpendLearning/Source/Data/Strategies/PredictionStrategy.swift b/SpendLearning/SpendLearning/Source/Data/Strategies/PredictionStrategy.swift new file mode 100644 index 0000000..d20b903 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Data/Strategies/PredictionStrategy.swift @@ -0,0 +1,15 @@ +// +// PredictionStrategy.swift +// SpendLearning +// +// Created by 김성훈 on 7/14/26. +// + +protocol PredictionStrategy { + /// 소비 패턴 기반 인사이트 목록 + func predictInsights(expenses: [Expense]) -> [AIInsightItem] + /// 일별 예측 지출 금액 [일: 예측금액] + func predictDaily(expenses: [Expense], year: Int, month: Int) -> [Int: Int] + /// 카테고리별 예측 지출 금액 [카테고리명: 예측금액] + func predictCategory(expenses: [Expense], year: Int, month: Int) -> [String: Int] +} diff --git a/SpendLearning/SpendLearning/Source/Data/Strategies/StatisticsPredictionStrategy.swift b/SpendLearning/SpendLearning/Source/Data/Strategies/StatisticsPredictionStrategy.swift new file mode 100644 index 0000000..7f6aaa3 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Data/Strategies/StatisticsPredictionStrategy.swift @@ -0,0 +1,355 @@ +// +// StatisticsPredictionStrategy.swift +// SpendLearning +// +// Created by 김성훈 on 7/14/26. +// + +import Foundation + +final class StatisticsPredictionStrategy: PredictionStrategy { + + // MARK: - predictInsights + + /// 이번 주 소비 패턴을 분석해 인사이트를 반환한다. + /// 이상 지출 → 지출 예고 → 미기록 감지 순서로 최대 3개까지 반환한다. + func predictInsights(expenses: [Expense]) -> [AIInsightItem] { + var insights: [AIInsightItem] = [] + + let calendar = Calendar.current + let now = Date() + let currentWeekOfYear = calendar.component(.weekOfYear, from: now) + let currentYear = calendar.component(.year, from: now) + + // 이번 주 지출 + let thisWeekExpenses = expenses.filter { + calendar.component(.weekOfYear, from: $0.date) == currentWeekOfYear && + calendar.component(.year, from: $0.date) == currentYear + } + + // 이상 지출 감지 + if let item = abnormalSpendingInsight( + thisWeek: thisWeekExpenses, + pastExpenses: expenses, + calendar: calendar + ) { + insights.append(item) + } + + // 지출 예고 + if let item = forecastInsight( + expenses: expenses, + calendar: calendar, + now: now + ) { + insights.append(item) + } + + // 미기록 감지 + if let item = unrecordedInsight( + thisWeek: thisWeekExpenses, + expenses: expenses, + calendar: calendar, + now: now + ) { + insights.append(item) + } + + return insights + } + + // MARK: - predictDaily + + /// 이번 달 일별 예측 지출 금액을 반환한다. + /// 폴백 순서: + /// 1. 과거 같은 주차 + 같은 요일 지출 평균 + /// 2. 데이터 없으면 → 과거 같은 주차 전체 지출 평균 + /// 3. 데이터 없으면 → 과거 월 평균 총액 / 이번 달 일수 + func predictDaily(expenses: [Expense], year: Int, month: Int) -> [Int: Int] { + let calendar = Calendar.current + guard let targetDate = calendar.date(from: DateComponents(year: year, month: month, day: 1)), + let dayRange = calendar.range(of: .day, in: .month, for: targetDate) else { + return [:] + } + + // 예측 대상 달(이번 달)은 아직 진행 중이므로 평균 계산에서 제외 + let pastExpenses = expenses.filter { + let y = calendar.component(.year, from: $0.date) + let m = calendar.component(.month, from: $0.date) + return !(y == year && m == month) + } + + let monthlyTotal = monthlyAverage(expenses: pastExpenses) + + var result: [Int: Int] = [:] + for day in dayRange { + guard let date = calendar.date(from: DateComponents(year: year, month: month, day: day)) else { continue } + let weekOfMonth = calendar.component(.weekOfMonth, from: date) + let weekday = calendar.component(.weekday, from: date) + + if let avg = weekOfMonthAndWeekdayAverage(expenses: pastExpenses, weekOfMonth: weekOfMonth, weekday: weekday), avg > 0 { + // 1순위: 같은 주차 + 같은 요일 평균 + result[day] = avg + } else if let avg = weekOfMonthAverage(expenses: pastExpenses, weekOfMonth: weekOfMonth), avg > 0 { + // 2순위: 같은 주차 전체 평균 + result[day] = avg + } else { + // 3순위: 월 평균 총액 / 일수 + result[day] = monthlyTotal / dayRange.count + } + } + return result + } + + // MARK: - predictCategory + + /// 이번 달 카테고리별 예측 지출 금액을 반환한다. + /// 과거 카테고리별 월 평균을 계산해 반환한다. + func predictCategory(expenses: [Expense], year: Int, month: Int) -> [String: Int] { + let calendar = Calendar.current + + // 예측 대상 달(이번 달)은 아직 진행 중이므로 평균 계산에서 제외 + let pastExpenses = expenses.filter { + let y = calendar.component(.year, from: $0.date) + let m = calendar.component(.month, from: $0.date) + return !(y == year && m == month) + } + + // 보유한 달 수 계산 + let months = Set(pastExpenses.map { + let y = calendar.component(.year, from: $0.date) + let m = calendar.component(.month, from: $0.date) + return "\(y)-\(m)" + }) + let monthCount = max(months.count, 1) + + // 카테고리별 총합 / 달 수 = 월 평균 + var categoryTotals: [String: Int] = [:] + for expense in pastExpenses { + categoryTotals[expense.category.name, default: 0] += expense.amount + } + + return categoryTotals.mapValues { $0 / monthCount } + } + + // MARK: - Private helpers (predictInsights) + + /// 이상 지출 감지 + /// 이번 주 카테고리 지출이 최근 4주 같은 카테고리 주 평균의 1.5배 이상이면 감지 + /// 여러 카테고리가 해당되면 배수가 가장 큰 것 하나만 반환 + /// "기타" 카테고리는 제외 + private func abnormalSpendingInsight( + thisWeek: [Expense], + pastExpenses: [Expense], + calendar: Calendar + ) -> AIInsightItem? { + let thisWeekByCategory = Dictionary(grouping: thisWeek.filter { $0.category.name != "기타" }, by: { $0.category.name }) + .mapValues { $0.reduce(0) { $0 + $1.amount } } + + var maxRatio: Double = 0 + var maxCategory: String? = nil + + for (category, thisAmount) in thisWeekByCategory { + // 최근 4주 같은 카테고리 지출 수집 + let recent4Weeks = (1...4).flatMap { offset -> [Expense] in + guard let pastWeekDate = calendar.date(byAdding: .weekOfYear, value: -offset, to: Date()) else { return [] } + let weekOfYear = calendar.component(.weekOfYear, from: pastWeekDate) + let year = calendar.component(.year, from: pastWeekDate) + return pastExpenses.filter { + $0.category.name == category && + calendar.component(.weekOfYear, from: $0.date) == weekOfYear && + calendar.component(.year, from: $0.date) == year + } + } + + // 과거 데이터가 없으면 비교 기준이 없으므로 제외 + guard !recent4Weeks.isEmpty else { continue } + + // 주 단위로 그룹핑해 주 평균 계산 + let weekGroups = Dictionary(grouping: recent4Weeks) { + "\(calendar.component(.year, from: $0.date))-\(calendar.component(.weekOfYear, from: $0.date))" + } + let avgPerWeek = recent4Weeks.reduce(0) { $0 + $1.amount } / max(weekGroups.count, 1) + + // 과거에 한 번도 쓴 적 없는 카테고리는 비교 기준이 없으므로 제외 + guard avgPerWeek > 0 else { continue } + + let ratio = Double(thisAmount) / Double(avgPerWeek) + + if ratio >= 1.5 && ratio > maxRatio { + maxRatio = ratio + maxCategory = category + } + } + + guard let category = maxCategory else { return nil } + return AIInsightItem( + type: .abnormal, + description: "이번 주 \(category) 지출이 평소보다 많아요" + ) + } + + /// 지출 예고 + /// 최근 3주 연속 같은 요일에 같은 카테고리 지출이 있고, 이번 주 해당 요일이 아직 안 지났으면 예고 + /// 가장 가까운 요일 우선, 같은 요일이면 금액이 가장 큰 것 반환 + /// "기타" 카테고리는 제외 + private func forecastInsight( + expenses: [Expense], + calendar: Calendar, + now: Date + ) -> AIInsightItem? { + let todayWeekday = calendar.component(.weekday, from: now) + guard todayWeekday < 7 else { return nil } + let filteredExpenses = expenses.filter { $0.category.name != "기타" } + + var candidates: [(weekday: Int, category: String, avgAmount: Int)] = [] + + for weekday in (todayWeekday + 1)...7 { + let recentMatches = (1...3).compactMap { offset -> (category: String, amount: Int)? in + guard let pastWeekDate = calendar.date(byAdding: .weekOfYear, value: -offset, to: now) else { return nil } + let weekOfYear = calendar.component(.weekOfYear, from: pastWeekDate) + let year = calendar.component(.year, from: pastWeekDate) + guard let expense = filteredExpenses.first(where: { + calendar.component(.weekday, from: $0.date) == weekday && + calendar.component(.weekOfYear, from: $0.date) == weekOfYear && + calendar.component(.year, from: $0.date) == year + }) else { return nil } + return (expense.category.name, expense.amount) + } + + // 3주 모두 같은 카테고리여야 패턴으로 인정 + guard recentMatches.count == 3, + let category = recentMatches.first?.category, + recentMatches.allSatisfy({ $0.category == category }) else { continue } + + let avgAmount = recentMatches.map { $0.amount }.reduce(0, +) / 3 + candidates.append((weekday: weekday, category: category, avgAmount: avgAmount)) + } + + guard !candidates.isEmpty else { return nil } + + // 가장 가까운 요일 우선, 같은 요일이면 금액 큰 것 + let best = candidates + .sorted { lhs, rhs in + if lhs.weekday != rhs.weekday { return lhs.weekday < rhs.weekday } + return lhs.avgAmount > rhs.avgAmount + } + .first! + + let weekdayName = ["일", "월", "화", "수", "목", "금", "토"][best.weekday - 1] + return AIInsightItem( + type: .forecast, + description: "매주 \(weekdayName)요일 \(best.category) 지출이 있는 패턴이에요" + ) + } + + /// 미기록 감지 + /// 최근 3주 연속 같은 요일에 같은 카테고리 지출이 있었는데, 이번 주 해당 요일이 이미 지났는데도 기록이 없으면 감지 + /// 가장 가까운 요일 우선, 같은 요일이면 금액이 가장 큰 것 반환 + /// "기타" 카테고리는 제외 + private func unrecordedInsight( + thisWeek: [Expense], + expenses: [Expense], + calendar: Calendar, + now: Date + ) -> AIInsightItem? { + let todayWeekday = calendar.component(.weekday, from: now) + let filteredExpenses = expenses.filter { $0.category.name != "기타" } + let thisWeekCategories = Set(thisWeek.filter { $0.category.name != "기타" }.map { $0.category.name }) + + var candidates: [(weekday: Int, category: String, avgAmount: Int)] = [] + + // 이미 지난 요일만 확인 + for weekday in 1...todayWeekday { + let recentMatches = (1...3).compactMap { offset -> (category: String, amount: Int)? in + guard let pastWeekDate = calendar.date(byAdding: .weekOfYear, value: -offset, to: now) else { return nil } + let weekOfYear = calendar.component(.weekOfYear, from: pastWeekDate) + let year = calendar.component(.year, from: pastWeekDate) + guard let expense = filteredExpenses.first(where: { + calendar.component(.weekday, from: $0.date) == weekday && + calendar.component(.weekOfYear, from: $0.date) == weekOfYear && + calendar.component(.year, from: $0.date) == year + }) else { return nil } + return (expense.category.name, expense.amount) + } + + // 3주 모두 같은 카테고리여야 패턴으로 인정 + guard recentMatches.count == 3, + let category = recentMatches.first?.category, + recentMatches.allSatisfy({ $0.category == category }) else { continue } + + // 이번 주 해당 요일에 같은 카테고리 기록이 없으면 감지 + guard !thisWeekCategories.contains(category) else { continue } + + let avgAmount = recentMatches.map { $0.amount }.reduce(0, +) / 3 + candidates.append((weekday: weekday, category: category, avgAmount: avgAmount)) + } + + guard !candidates.isEmpty else { return nil } + + // 가장 가까운 요일 우선, 같은 요일이면 금액 큰 것 + let best = candidates + .sorted { lhs, rhs in + if lhs.weekday != rhs.weekday { return lhs.weekday < rhs.weekday } + return lhs.avgAmount > rhs.avgAmount + } + .first! + + let weekdayName = ["일", "월", "화", "수", "목", "금", "토"][best.weekday - 1] + return AIInsightItem( + type: .unrecorded, + description: "매주 \(weekdayName)요일 \(best.category) 지출이 있는 패턴인데 이번 주엔 없네요" + ) + } + + // MARK: - Private helpers (predictDaily) + + /// 같은 주차 + 같은 요일에 해당하는 지출의 월 평균 + private func weekOfMonthAndWeekdayAverage(expenses: [Expense], weekOfMonth: Int, weekday: Int) -> Int? { + let calendar = Calendar.current + let matched = expenses.filter { + calendar.component(.weekOfMonth, from: $0.date) == weekOfMonth && + calendar.component(.weekday, from: $0.date) == weekday + } + guard !matched.isEmpty else { return nil } + + // 같은 주차+요일이 등장한 월 수로 나눠 평균 + let months = Set(matched.map { + let y = calendar.component(.year, from: $0.date) + let m = calendar.component(.month, from: $0.date) + return "\(y)-\(m)" + }) + let total = matched.reduce(0) { $0 + $1.amount } + return total / max(months.count, 1) + } + + /// 같은 주차 전체 지출의 주 평균 + private func weekOfMonthAverage(expenses: [Expense], weekOfMonth: Int) -> Int? { + let calendar = Calendar.current + let matched = expenses.filter { + calendar.component(.weekOfMonth, from: $0.date) == weekOfMonth + } + guard !matched.isEmpty else { return nil } + + // 같은 주차가 등장한 횟수(월별 주차)로 나눠 평균 + let weeks = Set(matched.map { + let y = calendar.component(.year, from: $0.date) + let m = calendar.component(.month, from: $0.date) + return "\(y)-\(m)-\(calendar.component(.weekOfMonth, from: $0.date))" + }) + let total = matched.reduce(0) { $0 + $1.amount } + return total / max(weeks.count, 1) + } + + /// 과거 데이터의 월 평균 총 지출 + private func monthlyAverage(expenses: [Expense]) -> Int { + let calendar = Calendar.current + let months = Set(expenses.map { + let y = calendar.component(.year, from: $0.date) + let m = calendar.component(.month, from: $0.date) + return "\(y)-\(m)" + }) + let total = expenses.reduce(0) { $0 + $1.amount } + return total / max(months.count, 1) + } +} diff --git a/SpendLearning/SpendLearning/Source/Domain/Entities/AI/AIInsightItem.swift b/SpendLearning/SpendLearning/Source/Domain/Entities/AI/AIInsightItem.swift index e3fdb2a..48aff8a 100644 --- a/SpendLearning/SpendLearning/Source/Domain/Entities/AI/AIInsightItem.swift +++ b/SpendLearning/SpendLearning/Source/Domain/Entities/AI/AIInsightItem.swift @@ -15,6 +15,14 @@ enum AIInsightItemType { case .unrecorded: return "미기록 감지" } } + + var emptyDescription: String { + switch self { + case .abnormal: return "이번 주 이상 지출 패턴이 없어요" + case .forecast: return "이번 주 예고할 지출 패턴이 없어요" + case .unrecorded: return "이번 주 미기록된 지출 패턴이 없어요" + } + } } struct AIInsightItem { diff --git a/SpendLearning/SpendLearning/Source/Domain/Entities/AI/AIModelCreationError.swift b/SpendLearning/SpendLearning/Source/Domain/Entities/AI/AIModelCreationError.swift new file mode 100644 index 0000000..eb63985 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Domain/Entities/AI/AIModelCreationError.swift @@ -0,0 +1,11 @@ +// +// AIModelCreationError.swift +// SpendLearning +// +// Created by 김성훈 on 7/14/26. +// + +enum AIModelCreationError: Error, Equatable { + /// 저번 달 소비 기록이 없어 모델 생성 불가 + case insufficientData +} diff --git a/SpendLearning/SpendLearning/Source/Domain/Entities/AI/AIModelMetadata.swift b/SpendLearning/SpendLearning/Source/Domain/Entities/AI/AIModelMetadata.swift index b7f9ded..a7288b8 100644 --- a/SpendLearning/SpendLearning/Source/Domain/Entities/AI/AIModelMetadata.swift +++ b/SpendLearning/SpendLearning/Source/Domain/Entities/AI/AIModelMetadata.swift @@ -10,6 +10,6 @@ import Foundation struct AIModelMetadata { let id: String let dataCount: Int - let accuracy: Float + let accuracy: Float? let createdAt: Date } diff --git a/SpendLearning/SpendLearning/Source/Domain/Repositories/AIRepositoryProtocol.swift b/SpendLearning/SpendLearning/Source/Domain/Repositories/AIRepositoryProtocol.swift index 3837218..47583e6 100644 --- a/SpendLearning/SpendLearning/Source/Domain/Repositories/AIRepositoryProtocol.swift +++ b/SpendLearning/SpendLearning/Source/Domain/Repositories/AIRepositoryProtocol.swift @@ -16,4 +16,8 @@ protocol AIRepositoryProtocol { func fetchDailyPredictions(year: Int, month: Int) async -> [Int: Int] /// 이번 달 카테고리별 예측 금액 [카테고리명: 예측금액] func fetchCategoryPredictions(year: Int, month: Int) async -> [String: Int] + /// 예측 모델 생성 및 저장 + func createModel(expenses: [Expense]) async -> Result + /// 예측 모델 선택 + func selectModel(id: String) async } diff --git a/SpendLearning/SpendLearning/Source/Domain/Repositories/ExpenseRepositoryProtocol.swift b/SpendLearning/SpendLearning/Source/Domain/Repositories/ExpenseRepositoryProtocol.swift index 88ef8b8..075afb0 100644 --- a/SpendLearning/SpendLearning/Source/Domain/Repositories/ExpenseRepositoryProtocol.swift +++ b/SpendLearning/SpendLearning/Source/Domain/Repositories/ExpenseRepositoryProtocol.swift @@ -9,6 +9,7 @@ import Foundation protocol ExpenseRepositoryProtocol { func fetchExpenses(year: Int, month: Int) async -> [Expense] + func fetchAllExpenses() async -> [Expense] func addExpense(_ expense: Expense) async func deleteExpense(_ expense: Expense) async } diff --git a/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCase.swift b/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCase.swift index 768831f..4cb3d8f 100644 --- a/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCase.swift +++ b/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCase.swift @@ -10,9 +10,14 @@ import Foundation final class AIUseCase: AIUseCaseProtocol { private let repository: AIRepositoryProtocol + private let expenseRepository: ExpenseRepositoryProtocol - init(repository: AIRepositoryProtocol) { + init( + repository: AIRepositoryProtocol, + expenseRepository: ExpenseRepositoryProtocol + ) { self.repository = repository + self.expenseRepository = expenseRepository } func fetchModels() async -> [AIModelMetadata] { @@ -34,4 +39,13 @@ final class AIUseCase: AIUseCaseProtocol { func fetchCategoryPredictions(year: Int, month: Int) async -> [String: Int] { await repository.fetchCategoryPredictions(year: year, month: month) } + + func createModel() async -> Result { + let expenses = await expenseRepository.fetchAllExpenses() + return await repository.createModel(expenses: expenses) + } + + func selectModel(id: String) async { + await repository.selectModel(id: id) + } } diff --git a/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCaseProtocol.swift b/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCaseProtocol.swift index 524bcc4..9d13c51 100644 --- a/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCaseProtocol.swift +++ b/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCaseProtocol.swift @@ -16,4 +16,8 @@ protocol AIUseCaseProtocol { func fetchDailyPredictions(year: Int, month: Int) async -> [Int: Int] /// 이번 달 카테고리별 예측 금액 [카테고리명: 예측금액] func fetchCategoryPredictions(year: Int, month: Int) async -> [String: Int] + /// 예측 모델 생성 + func createModel() async -> Result + /// 예측 모델 선택 + func selectModel(id: String) async } diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIInsightCardView.swift b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIInsightCardView.swift index 12567bc..1e3cd35 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIInsightCardView.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIInsightCardView.swift @@ -10,15 +10,15 @@ import SwiftUI struct AIInsightCardView: View { let items: [AIInsightItem] + let hasPrediction: Bool var body: some View { VStack(spacing: 0) { header - - if items.isEmpty { - emptyView - } else { + if hasPrediction { bodyView + } else { + emptyView } } .background(Color(UIColor.DesignSystem.surface)) diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIModelSelectView.swift b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIModelSelectView.swift index d773aa8..e054b8a 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIModelSelectView.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIModelSelectView.swift @@ -68,7 +68,7 @@ struct AIModelSelectView: View { .font(.system(size: 16, weight: .bold)) .foregroundStyle(Color(UIColor.DesignSystem.primary)) - Text("정확도 \(Int(model.accuracy))% · 데이터 \(model.dataCount)개") + Text((model.accuracy.map { "정확도 \(Int($0))% · " } ?? "정확도 측정불가 · ") + "데이터 \(model.dataCount)개") .font(.system(size: 14)) .foregroundStyle(Color(UIColor.DesignSystem.subtitle)) @@ -116,7 +116,7 @@ struct AIModelSelectView: View { private func formattedDate(_ date: Date) -> String { let formatter = DateFormatter() - formatter.dateFormat = "yyyy.MM.dd 생성" + formatter.dateFormat = "yyyy.MM.dd HH:mm 생성" return formatter.string(from: date) } } diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIStatusCardView.swift b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIStatusCardView.swift index 7e18ce4..d23d4c1 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIStatusCardView.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIStatusCardView.swift @@ -12,9 +12,14 @@ struct AIStatusCardView: View { @Binding var isShowingToast: Bool @State private var isShowingCreate = false @State private var isShowingSelect = false + @State private var isShowingInsufficientDataAlert = false let currentModel: AIModelMetadata? let models: [AIModelMetadata] + let isCreatingModel: Bool + let onCreateModel: () async -> Void + let createModelError: AIModelCreationError? + let onSelectModel: (AIModelMetadata) async -> Void var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -25,19 +30,29 @@ struct AIStatusCardView: View { .clipShape(RoundedRectangle(cornerRadius: 20)) .alert("모델 생성", isPresented: $isShowingCreate) { Button("생성하기") { - print("전체 데이터로 모델 생성") + Task { await onCreateModel() } } Button("취소", role: .cancel) {} } message: { Text("데이터를 학습해 예측 모델을 생성할까요?") } + .alert("데이터 부족", isPresented: $isShowingInsufficientDataAlert) { + Button("확인", role: .cancel) {} + } message: { + Text("저번 달 소비 기록이 없어 생성할 수 없어요.\n저번 달 소비를 기록하고 다시 시도해보세요.") + } + .onChange(of: createModelError) { _, error in + if error == .insufficientData { + isShowingInsufficientDataAlert = true + } + } .tint(Color(UIColor.DesignSystem.accent)) .sheet(isPresented: $isShowingSelect) { AIModelSelectView( models: models, currentModelId: currentModel?.id ?? "" ) { selected in - print("선택된 모델: \(selected.id)") + Task { await onSelectModel(selected) } } } } @@ -53,7 +68,7 @@ struct AIStatusCardView: View { .foregroundStyle(Color(UIColor.DesignSystem.primary)) VStack(alignment: .leading, spacing: 4) { - Text("\(model.id) 예측 모델 (\(Int(model.accuracy))%)") + Text("\(model.id) 예측 모델") .font(.system(size: 16, weight: .bold)) .foregroundStyle(Color(UIColor.DesignSystem.primary)) @@ -61,10 +76,16 @@ struct AIStatusCardView: View { .font(.system(size: 13, weight: .semibold)) .foregroundStyle(Color(UIColor.DesignSystem.subtitle)) - ProgressView(value: model.accuracy / 100) - .tint(Color(UIColor.DesignSystem.accent)) - .scaleEffect(x: 1, y: 2) - .padding(.top, 2) + if let accuracy = model.accuracy { + ProgressView(value: accuracy / 100) + .tint(Color(UIColor.DesignSystem.accent)) + .scaleEffect(x: 1, y: 2) + .padding(.top, 2) + } else { + Text("정확도 측정불가") + .font(.system(size: 13)) + .foregroundStyle(Color(UIColor.DesignSystem.subtitle)) + } } } } else { @@ -91,14 +112,22 @@ struct AIStatusCardView: View { Button { isShowingCreate = true } label: { - Text("모델 생성하기") - .font(.system(size: 16, weight: .bold)) - .foregroundStyle(.white) - .frame(maxWidth: .infinity) - .frame(height: 44) - .background(Color(UIColor.DesignSystem.primary)) - .clipShape(RoundedRectangle(cornerRadius: 14)) + Group { + if isCreatingModel { + ProgressView() + .tint(.white) + } else { + Text("모델 생성하기") + .font(.system(size: 16, weight: .bold)) + .foregroundStyle(.white) + } + } + .frame(maxWidth: .infinity) + .frame(height: 44) + .background(Color(UIColor.DesignSystem.primary)) + .clipShape(RoundedRectangle(cornerRadius: 14)) } + .disabled(isCreatingModel) Button { if models.isEmpty { diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIView.swift b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIView.swift index 037fc90..6bf13b5 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIView.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIView.swift @@ -11,6 +11,7 @@ struct AIView: View { @State var viewModel: AIViewModel @State private var isShowingToast = false + @State private var isShowingSuccessToast = false var body: some View { ScrollView { @@ -29,10 +30,21 @@ struct AIView: View { isShowingToast: $isShowingToast, currentModel: viewModel.currentModel, models: viewModel.models, + isCreatingModel: viewModel.isCreatingModel, + onCreateModel: { + await viewModel.createModel() + if viewModel.createModelError == nil { + isShowingSuccessToast = true + } + }, + createModelError: viewModel.createModelError, + onSelectModel: { selected in + await viewModel.selectModel(id: selected.id) + } ) } - AIInsightCardView(items: viewModel.sortedInsights) + AIInsightCardView(items: viewModel.sortedInsights, hasPrediction: viewModel.hasPrediction) AIPredictionCardView( data: viewModel.predictionData, @@ -57,5 +69,6 @@ struct AIView: View { } } .toast(isShowing: $isShowingToast, message: "아직 생성된 예측 모델이 없어요") + .toast(isShowing: $isShowingSuccessToast, message: "예측 모델이 생성되었어요") } } diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIViewModel.swift b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIViewModel.swift index 8966db9..9234956 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIViewModel.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIViewModel.swift @@ -22,18 +22,20 @@ final class AIViewModel { let range = calendar.range(of: .day, in: .month, for: Date()) return range?.count ?? 30 }() + private(set) var isCreatingModel: Bool = false + private(set) var createModelError: AIModelCreationError? = nil private let expenseUseCase: ExpenseUseCaseProtocol private let aiUseCase: AIUseCaseProtocol var hasPrediction: Bool { - predictionData.compactMap { $0.predicted }.isEmpty == false + currentModel != nil } var sortedInsights: [AIInsightItem] { let order: [AIInsightItemType] = [.abnormal, .forecast, .unrecorded] - return order.compactMap { type in - insights.first { $0.type == type } + return order.map { type in + insights.first { $0.type == type } ?? AIInsightItem(type: type, description: type.emptyDescription) } } @@ -51,6 +53,27 @@ final class AIViewModel { await load() } + func createModel() async { + isCreatingModel = true + createModelError = nil + + let result = await aiUseCase.createModel() + + switch result { + case .success: + await load() + case .failure(let error): + createModelError = error + } + + isCreatingModel = false + } + + func selectModel(id: String) async { + await aiUseCase.selectModel(id: id) + await load() + } + // MARK: - Private private func load() async { let calendar = Calendar.current diff --git a/SpendLearning/SpendLearningTests/UseCases/AIUseCaseTests.swift b/SpendLearning/SpendLearningTests/UseCases/AIUseCaseTests.swift index 2b12482..a651060 100644 --- a/SpendLearning/SpendLearningTests/UseCases/AIUseCaseTests.swift +++ b/SpendLearning/SpendLearningTests/UseCases/AIUseCaseTests.swift @@ -15,7 +15,7 @@ struct AIUseCaseTests { @Test("fetchModels 호출 시 Repository의 fetchModels가 호출된다") func fetchModelsCallsRepository() async { let spy = SpyAIRepository() - let sut = AIUseCase(repository: spy) + let sut = AIUseCase(repository: spy, expenseRepository: StubExpenseRepository()) _ = await sut.fetchModels() @@ -27,7 +27,7 @@ struct AIUseCaseTests { let spy = SpyAIRepository() let model = AIModelMetadata(id: "SP260714", dataCount: 1204, accuracy: 82, createdAt: Date()) spy.stubbedModels = [model] - let sut = AIUseCase(repository: spy) + let sut = AIUseCase(repository: spy, expenseRepository: StubExpenseRepository()) let result = await sut.fetchModels() @@ -40,7 +40,7 @@ struct AIUseCaseTests { let spy = SpyAIRepository() let model = AIModelMetadata(id: "SP260714", dataCount: 1204, accuracy: 82, createdAt: Date()) spy.stubbedCurrentModel = model - let sut = AIUseCase(repository: spy) + let sut = AIUseCase(repository: spy, expenseRepository: StubExpenseRepository()) let result = await sut.fetchCurrentModel() @@ -50,7 +50,7 @@ struct AIUseCaseTests { @Test("fetchDailyPredictions 호출 시 Repository가 올바른 year/month로 호출된다") func fetchDailyPredictionsCallsRepositoryWithCorrectYearMonth() async { let spy = SpyAIRepository() - let sut = AIUseCase(repository: spy) + let sut = AIUseCase(repository: spy, expenseRepository: StubExpenseRepository()) _ = await sut.fetchDailyPredictions(year: 2026, month: 7) @@ -62,7 +62,7 @@ struct AIUseCaseTests { @Test("fetchCategoryPredictions 호출 시 Repository가 올바른 year/month로 호출된다") func fetchCategoryPredictionsCallsRepositoryWithCorrectYearMonth() async { let spy = SpyAIRepository() - let sut = AIUseCase(repository: spy) + let sut = AIUseCase(repository: spy, expenseRepository: StubExpenseRepository()) _ = await sut.fetchCategoryPredictions(year: 2026, month: 7) @@ -76,13 +76,54 @@ struct AIUseCaseTests { let spy = SpyAIRepository() let insight = AIInsightItem(type: .abnormal, description: "테스트") spy.stubbedInsights = [insight] - let sut = AIUseCase(repository: spy) + let sut = AIUseCase(repository: spy, expenseRepository: StubExpenseRepository()) let result = await sut.fetchInsights() #expect(result.count == 1) #expect(result.first?.description == insight.description) } + + @Test("createModel 호출 시 Repository의 createModel이 호출된다") + func createModelCallsRepository() async { + let spy = SpyAIRepository() + let sut = AIUseCase(repository: spy, expenseRepository: StubExpenseRepository()) + + _ = await sut.createModel() + + #expect(spy.createModelCallCount == 1) + } + + @Test("createModel 성공 시 생성된 모델을 반환한다") + func createModelReturnsModelOnSuccess() async { + let spy = SpyAIRepository() + let model = AIModelMetadata(id: "SP260714", dataCount: 10, accuracy: 50, createdAt: Date()) + spy.stubbedCreateModelResult = .success(model) + let sut = AIUseCase(repository: spy, expenseRepository: StubExpenseRepository()) + + let result = await sut.createModel() + + if case .success(let created) = result { + #expect(created.id == "SP260714") + } else { + Issue.record("성공을 기대했지만 실패 반환") + } + } + + @Test("createModel 실패 시 insufficientData 에러를 반환한다") + func createModelReturnsErrorOnInsufficientData() async { + let spy = SpyAIRepository() + spy.stubbedCreateModelResult = .failure(.insufficientData) + let sut = AIUseCase(repository: spy, expenseRepository: StubExpenseRepository()) + + let result = await sut.createModel() + + if case .failure(let error) = result { + #expect(error == .insufficientData) + } else { + Issue.record("실패를 기대했지만 성공 반환") + } + } } // MARK: - Spy @@ -95,9 +136,12 @@ final class SpyAIRepository: AIRepositoryProtocol { private(set) var fetchInsightsCallCount = 0 private(set) var fetchedYear: Int? private(set) var fetchedMonth: Int? + private(set) var createModelCallCount = 0 + var stubbedModels: [AIModelMetadata] = [] var stubbedCurrentModel: AIModelMetadata? = nil var stubbedInsights: [AIInsightItem] = [] + var stubbedCreateModelResult: Result = .failure(.insufficientData) func fetchModels() async -> [AIModelMetadata] { fetchModelsCallCount += 1 @@ -127,4 +171,20 @@ final class SpyAIRepository: AIRepositoryProtocol { fetchInsightsCallCount += 1 return stubbedInsights } + + func createModel(expenses: [Expense]) async -> Result { + createModelCallCount += 1 + return stubbedCreateModelResult + } + + func selectModel(id: String) async {} +} + +// MARK: - Stub + +final class StubExpenseRepository: ExpenseRepositoryProtocol { + func fetchExpenses(year: Int, month: Int) async -> [Expense] { [] } + func fetchAllExpenses() async -> [Expense] { [] } + func addExpense(_ expense: Expense) async {} + func deleteExpense(_ expense: Expense) async {} } diff --git a/SpendLearning/SpendLearningTests/UseCases/ExpenseUseCaseTests.swift b/SpendLearning/SpendLearningTests/UseCases/ExpenseUseCaseTests.swift index 6cf09fb..c33dc70 100644 --- a/SpendLearning/SpendLearningTests/UseCases/ExpenseUseCaseTests.swift +++ b/SpendLearning/SpendLearningTests/UseCases/ExpenseUseCaseTests.swift @@ -67,6 +67,10 @@ final class SpyExpenseRepository: ExpenseRepositoryProtocol { return stubbedExpenses } + func fetchAllExpenses() async -> [Expense] { + return stubbedExpenses + } + func addExpense(_ expense: Expense) async {} func deleteExpense(_ expense: Expense) async { diff --git a/SpendLearning/SpendLearningTests/ViewModels/AIViewModelTests.swift b/SpendLearning/SpendLearningTests/ViewModels/AIViewModelTests.swift index 6869a43..2d5d6f0 100644 --- a/SpendLearning/SpendLearningTests/ViewModels/AIViewModelTests.swift +++ b/SpendLearning/SpendLearningTests/ViewModels/AIViewModelTests.swift @@ -56,7 +56,7 @@ struct AIViewModelTests { @Test("예측 데이터가 있으면 hasPrediction이 true다") func hasPredictionIsTrueWhenPredictionsExist() async { let aiStub = StubAIUseCase() - aiStub.stubbedDailyPredictions = [1: 10000] + aiStub.stubbedCurrentModel = AIModelMetadata(id: "SPa1b2c3", dataCount: 10, accuracy: nil, createdAt: Date()) let sut = AIViewModel(expenseUseCase: StubExpenseUseCaseForAI(), aiUseCase: aiStub) await sut.onAppear() @@ -164,10 +164,13 @@ final class StubAIUseCase: AIUseCaseProtocol { var stubbedInsights: [AIInsightItem] = [] var stubbedDailyPredictions: [Int: Int] = [:] var stubbedCategoryPredictions: [String: Int] = [:] + var stubbedCreateModelResult: Result = .failure(.insufficientData) func fetchModels() async -> [AIModelMetadata] { stubbedModels } func fetchCurrentModel() async -> AIModelMetadata? { stubbedCurrentModel } func fetchInsights() async -> [AIInsightItem] { stubbedInsights } func fetchDailyPredictions(year: Int, month: Int) async -> [Int: Int] { stubbedDailyPredictions } func fetchCategoryPredictions(year: Int, month: Int) async -> [String: Int] { stubbedCategoryPredictions } + func createModel() async -> Result { stubbedCreateModelResult } + func selectModel(id: String) async {} }