From 31f6404996969ad735c6dbbdf21a6421507de646 Mon Sep 17 00:00:00 2001 From: snughnu Date: Tue, 14 Jul 2026 21:07:59 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20PredictionStrategy=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/Strategies/PredictionStrategy.swift | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 SpendLearning/SpendLearning/Source/Data/Strategies/PredictionStrategy.swift 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] +} From 72e6f9423297ba2c2fb778ea30c66dfb34ec4c9a Mon Sep 17 00:00:00 2001 From: snughnu Date: Tue, 14 Jul 2026 22:22:43 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20StatisticsPredictionStrategy=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../StatisticsPredictionStrategy.swift | 355 ++++++++++++++++++ 1 file changed, 355 insertions(+) create mode 100644 SpendLearning/SpendLearning/Source/Data/Strategies/StatisticsPredictionStrategy.swift 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) + } +} From 106eaed10d2b00f59fcd0a094475ab00fa1bc176 Mon Sep 17 00:00:00 2001 From: snughnu Date: Tue, 14 Jul 2026 22:40:31 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20ExpenseRepositoryProtocol=EC=97=90?= =?UTF-8?q?=20=EC=A0=84=EC=B2=B4=20=EA=B8=B0=EA=B0=84=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Source/Data/Repositories/MockExpenseRepository.swift | 4 ++++ .../Data/Repositories/SwiftDataExpenseRepository.swift | 7 +++++++ .../Domain/Repositories/ExpenseRepositoryProtocol.swift | 1 + 3 files changed, 12 insertions(+) diff --git a/SpendLearning/SpendLearning/Source/Data/Repositories/MockExpenseRepository.swift b/SpendLearning/SpendLearning/Source/Data/Repositories/MockExpenseRepository.swift index c1d4542..723cc22 100644 --- a/SpendLearning/SpendLearning/Source/Data/Repositories/MockExpenseRepository.swift +++ b/SpendLearning/SpendLearning/Source/Data/Repositories/MockExpenseRepository.swift @@ -57,6 +57,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/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/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 } From b83ec21fc4f3a659c0593eda688090686f91f24c Mon Sep 17 00:00:00 2001 From: snughnu Date: Tue, 14 Jul 2026 22:41:56 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20StatisticsAIRepository=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Repositories/StatisticsAIRepository.swift | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 SpendLearning/SpendLearning/Source/Data/Repositories/StatisticsAIRepository.swift diff --git a/SpendLearning/SpendLearning/Source/Data/Repositories/StatisticsAIRepository.swift b/SpendLearning/SpendLearning/Source/Data/Repositories/StatisticsAIRepository.swift new file mode 100644 index 0000000..96e4dbc --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Data/Repositories/StatisticsAIRepository.swift @@ -0,0 +1,89 @@ +// +// StatisticsAIRepository.swift +// SpendLearning +// +// Created by 김성훈 on 7/14/26. +// + +import Foundation + +final class StatisticsAIRepository: AIRepositoryProtocol { + + private let expenseRepository: ExpenseRepositoryProtocol + private let strategy = StatisticsPredictionStrategy() + + init(expenseRepository: ExpenseRepositoryProtocol) { + self.expenseRepository = expenseRepository + } + + func fetchModels() async -> [AIModelMetadata] { + let expenses = await expenseRepository.fetchAllExpenses() + guard let metadata = makeMetadata(expenses: expenses) else { return [] } + return [metadata] + } + + func fetchCurrentModel() async -> AIModelMetadata? { + let expenses = await expenseRepository.fetchAllExpenses() + return makeMetadata(expenses: expenses) + } + + func fetchInsights() async -> [AIInsightItem] { + let expenses = await expenseRepository.fetchAllExpenses() + guard isSufficientData(expenses: expenses) else { return [] } + return strategy.predictInsights(expenses: expenses) + } + + func fetchDailyPredictions(year: Int, month: Int) async -> [Int: Int] { + let expenses = await expenseRepository.fetchAllExpenses() + guard isSufficientData(expenses: expenses) else { return [:] } + return strategy.predictDaily(expenses: expenses, year: year, month: month) + } + + func fetchCategoryPredictions(year: Int, month: Int) async -> [String: Int] { + let expenses = await expenseRepository.fetchAllExpenses() + guard isSufficientData(expenses: expenses) else { return [:] } + return strategy.predictCategory(expenses: expenses, year: year, month: month) + } + + // MARK: - Private + + /// 데이터가 예측에 충분한지 확인 (1개월 이상) + private func isSufficientData(expenses: [Expense]) -> Bool { + 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)" + }) + return months.count >= 1 + } + + /// 소비 데이터 기반으로 모델 메타데이터 생성 + /// 데이터가 부족하면 nil 반환 (currentModel = nil 상태) + private func makeMetadata(expenses: [Expense]) -> AIModelMetadata? { + guard isSufficientData(expenses: expenses) else { return nil } + + 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)" + }) + + // 데이터 양에 따라 정확도 추정 + // 1개월: 60%, 2개월: 70%, 3개월 이상: 80% + let accuracy: Float + switch months.count { + case 1: accuracy = 60 + case 2: accuracy = 70 + default: accuracy = 80 + } + + return AIModelMetadata( + id: "SP-STATS", + dataCount: expenses.count, + accuracy: accuracy, + createdAt: Date() + ) + } +} From ad01366db951c6f4e79de26efd6818071ee4307f Mon Sep 17 00:00:00 2001 From: snughnu Date: Tue, 14 Jul 2026 23:03:50 +0900 Subject: [PATCH 5/8] =?UTF-8?q?feat:=20AIModelCreationError=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SpendLearning.xcodeproj/project.pbxproj | 1 + .../Source/App/SceneDelegate.swift | 4 +- .../Repositories/MockExpenseRepository.swift | 141 ++++++++++++++---- .../Entities/AI/AIModelCreationError.swift | 11 ++ 4 files changed, 130 insertions(+), 27 deletions(-) create mode 100644 SpendLearning/SpendLearning/Source/Domain/Entities/AI/AIModelCreationError.swift 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..9dbe5ec 100644 --- a/SpendLearning/SpendLearning/Source/App/SceneDelegate.swift +++ b/SpendLearning/SpendLearning/Source/App/SceneDelegate.swift @@ -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: StatisticsAIRepository(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/Repositories/MockExpenseRepository.swift b/SpendLearning/SpendLearning/Source/Data/Repositories/MockExpenseRepository.swift index 723cc22..7f8ef77 100644 --- a/SpendLearning/SpendLearning/Source/Data/Repositories/MockExpenseRepository.swift +++ b/SpendLearning/SpendLearning/Source/Data/Repositories/MockExpenseRepository.swift @@ -13,14 +13,6 @@ final class MockExpenseRepository: ExpenseRepositoryProtocol { 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 food = Category(name: "식비", emoji: "🍚") let cafe = Category(name: "카페/간식", emoji: "☕️") @@ -28,24 +20,123 @@ 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 { + var components = DateComponents() + let targetMonth = month + monthOffset + let targetYear = year + (targetMonth - 1) / 12 + components.year = targetYear + components.month = ((targetMonth - 1 + 12) % 12) + 1 + components.day = day + return calendar.date(from: components)! + } + + // 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), + Expense(date: date(monthOffset: 0, day: 13), category: cafe, memo: "투썸플레이스", amount: 6200), + Expense(date: date(monthOffset: 0, day: 15), category: food, memo: "점심", amount: 9000), + Expense(date: date(monthOffset: 0, day: 15), category: cafe, memo: "스타벅스", amount: 6000), + Expense(date: date(monthOffset: 0, day: 15), category: transport, memo: "택시", amount: 12000), + Expense(date: date(monthOffset: 0, day: 18), category: shopping, memo: "올리브영", amount: 9800), + Expense(date: date(monthOffset: 0, day: 20), category: food, memo: "저녁", amount: 67000), + Expense(date: date(monthOffset: 0, day: 22), category: cafe, memo: "카페", amount: 15000), + Expense(date: date(monthOffset: 0, day: 25), category: shopping, memo: "쿠팡", amount: 120000), + Expense(date: date(monthOffset: 0, day: 28), category: transport, memo: "지하철", amount: 4500), + Expense(date: date(monthOffset: 0, day: 30), category: food, memo: "외식", amount: 32000), + ] + + // MARK: - 1달 전 + 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), ] + + // MARK: - 2달 전 + 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), + ] + + // MARK: - 3달 전 + 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), + ] + + // MARK: - 4달 전 + 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), + ] + + // MARK: - 5달 전 + 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 + + oneMonthAgo + + twoMonthsAgo + + threeMonthsAgo + + fourMonthsAgo + + fiveMonthsAgo }() func fetchExpenses(year: Int, month: Int) async -> [Expense] { 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..62781c4 --- /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 { + /// 저번 달 소비 기록이 없어 모델 생성 불가 + case insufficientData +} From 74021128eeb699a9d4703c6f3a249190d807eb6a Mon Sep 17 00:00:00 2001 From: snughnu Date: Wed, 15 Jul 2026 00:20:53 +0900 Subject: [PATCH 6/8] =?UTF-8?q?feat:=20AI=20=ED=86=B5=EA=B3=84=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Source/App/SceneDelegate.swift | 6 +- .../Source/Data/Models/AIModelModel.swift | 30 +++++ .../Data/Repositories/MockAIRepository.swift | 11 +- .../Repositories/MockExpenseRepository.swift | 66 ++++++++--- .../Repositories/StatisticsAIRepository.swift | 89 --------------- .../Repositories/SwiftDataAIRepository.swift | 108 ++++++++++++++++++ .../Domain/Entities/AI/AIInsightItem.swift | 8 ++ .../Entities/AI/AIModelCreationError.swift | 2 +- .../Domain/Entities/AI/AIModelMetadata.swift | 2 +- .../Repositories/AIRepositoryProtocol.swift | 2 + .../Source/Domain/UseCases/AIUseCase.swift | 12 +- .../Domain/UseCases/AIUseCaseProtocol.swift | 2 + .../View/AI/AIInsightCardView.swift | 8 +- .../View/AI/AIModelSelectView.swift | 4 +- .../View/AI/AIStatusCardView.swift | 54 ++++++--- .../Source/Presentation/View/AI/AIView.swift | 12 +- .../Presentation/View/AI/AIViewModel.swift | 24 +++- .../UseCases/AIUseCaseTests.swift | 70 +++++++++++- .../UseCases/ExpenseUseCaseTests.swift | 4 + .../ViewModels/AIViewModelTests.swift | 2 + 20 files changed, 370 insertions(+), 146 deletions(-) create mode 100644 SpendLearning/SpendLearning/Source/Data/Models/AIModelModel.swift delete mode 100644 SpendLearning/SpendLearning/Source/Data/Repositories/StatisticsAIRepository.swift create mode 100644 SpendLearning/SpendLearning/Source/Data/Repositories/SwiftDataAIRepository.swift diff --git a/SpendLearning/SpendLearning/Source/App/SceneDelegate.swift b/SpendLearning/SpendLearning/Source/App/SceneDelegate.swift index 9dbe5ec..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) ) } @@ -48,7 +48,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { selectedImage: UIImage(systemName: "house.fill") ) - let aiUseCase = AIUseCase(repository: StatisticsAIRepository(expenseRepository: expenseRepository)) + 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..a7b6444 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Data/Models/AIModelModel.swift @@ -0,0 +1,30 @@ +// +// 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 + + init( + id: String, + dataCount: Int, + accuracy: Float?, + createdAt: Date + ) { + self.id = id + self.dataCount = dataCount + self.accuracy = accuracy + self.createdAt = createdAt + } +} diff --git a/SpendLearning/SpendLearning/Source/Data/Repositories/MockAIRepository.swift b/SpendLearning/SpendLearning/Source/Data/Repositories/MockAIRepository.swift index 6c6d5dd..10e819b 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,9 @@ 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) + } } diff --git a/SpendLearning/SpendLearning/Source/Data/Repositories/MockExpenseRepository.swift b/SpendLearning/SpendLearning/Source/Data/Repositories/MockExpenseRepository.swift index 7f8ef77..6f06fba 100644 --- a/SpendLearning/SpendLearning/Source/Data/Repositories/MockExpenseRepository.swift +++ b/SpendLearning/SpendLearning/Source/Data/Repositories/MockExpenseRepository.swift @@ -11,8 +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()) + 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: "☕️") @@ -20,17 +21,26 @@ final class MockExpenseRepository: ExpenseRepositoryProtocol { let shopping = Category(name: "쇼핑", emoji: "🛍️") let medical = Category(name: "의료/건강", emoji: "💊") + // 특정 달의 특정 일 날짜 생성 func date(monthOffset: Int, day: Int) -> Date { - var components = DateComponents() 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)! } - // MARK: - 이번 달 + // 오늘 기준 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), @@ -38,19 +48,37 @@ final class MockExpenseRepository: ExpenseRepositoryProtocol { 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), - Expense(date: date(monthOffset: 0, day: 13), category: cafe, memo: "투썸플레이스", amount: 6200), - Expense(date: date(monthOffset: 0, day: 15), category: food, memo: "점심", amount: 9000), - Expense(date: date(monthOffset: 0, day: 15), category: cafe, memo: "스타벅스", amount: 6000), - Expense(date: date(monthOffset: 0, day: 15), category: transport, memo: "택시", amount: 12000), - Expense(date: date(monthOffset: 0, day: 18), category: shopping, memo: "올리브영", amount: 9800), - Expense(date: date(monthOffset: 0, day: 20), category: food, memo: "저녁", amount: 67000), - Expense(date: date(monthOffset: 0, day: 22), category: cafe, memo: "카페", amount: 15000), - Expense(date: date(monthOffset: 0, day: 25), category: shopping, memo: "쿠팡", amount: 120000), - Expense(date: date(monthOffset: 0, day: 28), category: transport, memo: "지하철", amount: 4500), - Expense(date: date(monthOffset: 0, day: 30), category: food, memo: "외식", amount: 32000), ] - // MARK: - 1달 전 + // 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), @@ -67,7 +95,6 @@ final class MockExpenseRepository: ExpenseRepositoryProtocol { Expense(date: date(monthOffset: -1, day: 29), category: food, memo: "외식", amount: 28000), ] - // MARK: - 2달 전 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), @@ -83,7 +110,6 @@ final class MockExpenseRepository: ExpenseRepositoryProtocol { Expense(date: date(monthOffset: -2, day: 28), category: shopping, memo: "쿠팡", amount: 75000), ] - // MARK: - 3달 전 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), @@ -99,7 +125,6 @@ final class MockExpenseRepository: ExpenseRepositoryProtocol { Expense(date: date(monthOffset: -3, day: 29), category: shopping, memo: "쿠팡", amount: 65000), ] - // MARK: - 4달 전 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), @@ -115,7 +140,6 @@ final class MockExpenseRepository: ExpenseRepositoryProtocol { Expense(date: date(monthOffset: -4, day: 28), category: shopping, memo: "쿠팡", amount: 88000), ] - // MARK: - 5달 전 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), @@ -132,6 +156,10 @@ final class MockExpenseRepository: ExpenseRepositoryProtocol { ] return thisMonth + + abnormalThisWeek + + abnormalPast + + forecastPast + + unrecordedPast + oneMonthAgo + twoMonthsAgo + threeMonthsAgo diff --git a/SpendLearning/SpendLearning/Source/Data/Repositories/StatisticsAIRepository.swift b/SpendLearning/SpendLearning/Source/Data/Repositories/StatisticsAIRepository.swift deleted file mode 100644 index 96e4dbc..0000000 --- a/SpendLearning/SpendLearning/Source/Data/Repositories/StatisticsAIRepository.swift +++ /dev/null @@ -1,89 +0,0 @@ -// -// StatisticsAIRepository.swift -// SpendLearning -// -// Created by 김성훈 on 7/14/26. -// - -import Foundation - -final class StatisticsAIRepository: AIRepositoryProtocol { - - private let expenseRepository: ExpenseRepositoryProtocol - private let strategy = StatisticsPredictionStrategy() - - init(expenseRepository: ExpenseRepositoryProtocol) { - self.expenseRepository = expenseRepository - } - - func fetchModels() async -> [AIModelMetadata] { - let expenses = await expenseRepository.fetchAllExpenses() - guard let metadata = makeMetadata(expenses: expenses) else { return [] } - return [metadata] - } - - func fetchCurrentModel() async -> AIModelMetadata? { - let expenses = await expenseRepository.fetchAllExpenses() - return makeMetadata(expenses: expenses) - } - - func fetchInsights() async -> [AIInsightItem] { - let expenses = await expenseRepository.fetchAllExpenses() - guard isSufficientData(expenses: expenses) else { return [] } - return strategy.predictInsights(expenses: expenses) - } - - func fetchDailyPredictions(year: Int, month: Int) async -> [Int: Int] { - let expenses = await expenseRepository.fetchAllExpenses() - guard isSufficientData(expenses: expenses) else { return [:] } - return strategy.predictDaily(expenses: expenses, year: year, month: month) - } - - func fetchCategoryPredictions(year: Int, month: Int) async -> [String: Int] { - let expenses = await expenseRepository.fetchAllExpenses() - guard isSufficientData(expenses: expenses) else { return [:] } - return strategy.predictCategory(expenses: expenses, year: year, month: month) - } - - // MARK: - Private - - /// 데이터가 예측에 충분한지 확인 (1개월 이상) - private func isSufficientData(expenses: [Expense]) -> Bool { - 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)" - }) - return months.count >= 1 - } - - /// 소비 데이터 기반으로 모델 메타데이터 생성 - /// 데이터가 부족하면 nil 반환 (currentModel = nil 상태) - private func makeMetadata(expenses: [Expense]) -> AIModelMetadata? { - guard isSufficientData(expenses: expenses) else { return nil } - - 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)" - }) - - // 데이터 양에 따라 정확도 추정 - // 1개월: 60%, 2개월: 70%, 3개월 이상: 80% - let accuracy: Float - switch months.count { - case 1: accuracy = 60 - case 2: accuracy = 70 - default: accuracy = 80 - } - - return AIModelMetadata( - id: "SP-STATS", - dataCount: expenses.count, - accuracy: accuracy, - createdAt: Date() - ) - } -} diff --git a/SpendLearning/SpendLearning/Source/Data/Repositories/SwiftDataAIRepository.swift b/SpendLearning/SpendLearning/Source/Data/Repositories/SwiftDataAIRepository.swift new file mode 100644 index 0000000..62da6ab --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Data/Repositories/SwiftDataAIRepository.swift @@ -0,0 +1,108 @@ +// +// 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( + sortBy: [SortDescriptor(\.createdAt, order: .reverse)] + ) + let models = (try? modelContext.fetch(descriptor)) ?? [] + return models.map { toMetadata($0) } + } + + func fetchCurrentModel() async -> AIModelMetadata? { + let descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.createdAt, order: .reverse)] + ) + let model = try? modelContext.fetch(descriptor).first + return model.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 id = "SP\(UUID().uuidString.prefix(6).lowercased())" + let metadata = AIModelMetadata( + id: id, + dataCount: expenses.count, + accuracy: nil, + createdAt: now + ) + + let model = AIModelModel( + id: metadata.id, + dataCount: metadata.dataCount, + accuracy: nil, + createdAt: metadata.createdAt + ) + modelContext.insert(model) + try? modelContext.save() + + return .success(metadata) + } + + // 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/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 index 62781c4..eb63985 100644 --- a/SpendLearning/SpendLearning/Source/Domain/Entities/AI/AIModelCreationError.swift +++ b/SpendLearning/SpendLearning/Source/Domain/Entities/AI/AIModelCreationError.swift @@ -5,7 +5,7 @@ // Created by 김성훈 on 7/14/26. // -enum AIModelCreationError: Error { +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..e4508b3 100644 --- a/SpendLearning/SpendLearning/Source/Domain/Repositories/AIRepositoryProtocol.swift +++ b/SpendLearning/SpendLearning/Source/Domain/Repositories/AIRepositoryProtocol.swift @@ -16,4 +16,6 @@ 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 } diff --git a/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCase.swift b/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCase.swift index 768831f..f7c89c0 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,9 @@ 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) + } } diff --git a/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCaseProtocol.swift b/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCaseProtocol.swift index 524bcc4..a3f08da 100644 --- a/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCaseProtocol.swift +++ b/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCaseProtocol.swift @@ -16,4 +16,6 @@ protocol AIUseCaseProtocol { func fetchDailyPredictions(year: Int, month: Int) async -> [Int: Int] /// 이번 달 카테고리별 예측 금액 [카테고리명: 예측금액] func fetchCategoryPredictions(year: Int, month: Int) async -> [String: Int] + /// 예측 모델 생성 + func createModel() async -> Result } 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..7f5cc7e 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIStatusCardView.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIStatusCardView.swift @@ -12,9 +12,13 @@ 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? var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -25,12 +29,22 @@ 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( @@ -53,7 +67,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 +75,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 +111,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..8026420 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,18 @@ 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 ) } - AIInsightCardView(items: viewModel.sortedInsights) + AIInsightCardView(items: viewModel.sortedInsights, hasPrediction: viewModel.hasPrediction) AIPredictionCardView( data: viewModel.predictionData, @@ -57,5 +66,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..771f037 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,22 @@ 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 + } + // 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..590629a 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,18 @@ final class SpyAIRepository: AIRepositoryProtocol { fetchInsightsCallCount += 1 return stubbedInsights } + + func createModel(expenses: [Expense]) async -> Result { + createModelCallCount += 1 + return stubbedCreateModelResult + } +} + +// 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..23b683b 100644 --- a/SpendLearning/SpendLearningTests/ViewModels/AIViewModelTests.swift +++ b/SpendLearning/SpendLearningTests/ViewModels/AIViewModelTests.swift @@ -164,10 +164,12 @@ 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 } } From e7f676eb3094912891433a388d7c3cdbafaa25e1 Mon Sep 17 00:00:00 2001 From: snughnu Date: Wed, 15 Jul 2026 00:35:01 +0900 Subject: [PATCH 7/8] =?UTF-8?q?feat:=20=EB=AA=A8=EB=8D=B8=20=EA=B5=90?= =?UTF-8?q?=EC=B2=B4=20=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Source/Data/Models/AIModelModel.swift | 5 ++- .../Data/Repositories/MockAIRepository.swift | 2 + .../Repositories/SwiftDataAIRepository.swift | 45 +++++++++++-------- .../Repositories/AIRepositoryProtocol.swift | 2 + .../Source/Domain/UseCases/AIUseCase.swift | 4 ++ .../Domain/UseCases/AIUseCaseProtocol.swift | 2 + .../View/AI/AIStatusCardView.swift | 3 +- .../Source/Presentation/View/AI/AIView.swift | 5 ++- .../Presentation/View/AI/AIViewModel.swift | 5 +++ 9 files changed, 51 insertions(+), 22 deletions(-) diff --git a/SpendLearning/SpendLearning/Source/Data/Models/AIModelModel.swift b/SpendLearning/SpendLearning/Source/Data/Models/AIModelModel.swift index a7b6444..19274ad 100644 --- a/SpendLearning/SpendLearning/Source/Data/Models/AIModelModel.swift +++ b/SpendLearning/SpendLearning/Source/Data/Models/AIModelModel.swift @@ -15,16 +15,19 @@ final class AIModelModel { var dataCount: Int var accuracy: Float? var createdAt: Date + var isSelected: Bool init( id: String, dataCount: Int, accuracy: Float?, - createdAt: Date + 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 10e819b..4fce474 100644 --- a/SpendLearning/SpendLearning/Source/Data/Repositories/MockAIRepository.swift +++ b/SpendLearning/SpendLearning/Source/Data/Repositories/MockAIRepository.swift @@ -57,4 +57,6 @@ final class MockAIRepository: AIRepositoryProtocol { 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/SwiftDataAIRepository.swift b/SpendLearning/SpendLearning/Source/Data/Repositories/SwiftDataAIRepository.swift index 62da6ab..8a6f137 100644 --- a/SpendLearning/SpendLearning/Source/Data/Repositories/SwiftDataAIRepository.swift +++ b/SpendLearning/SpendLearning/Source/Data/Repositories/SwiftDataAIRepository.swift @@ -23,19 +23,20 @@ final class SwiftDataAIRepository: AIRepositoryProtocol { } func fetchModels() async -> [AIModelMetadata] { - let descriptor = FetchDescriptor( - sortBy: [SortDescriptor(\.createdAt, order: .reverse)] - ) + let descriptor = FetchDescriptor() let models = (try? modelContext.fetch(descriptor)) ?? [] - return models.map { toMetadata($0) } + 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( - sortBy: [SortDescriptor(\.createdAt, order: .reverse)] - ) - let model = try? modelContext.fetch(descriptor).first - return model.map { toMetadata($0) } + let descriptor = FetchDescriptor() + let models = (try? modelContext.fetch(descriptor)) ?? [] + return models.first { $0.isSelected }.map { toMetadata($0) } } func fetchInsights() async -> [AIInsightItem] { @@ -70,24 +71,30 @@ final class SwiftDataAIRepository: AIRepositoryProtocol { } 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 metadata = AIModelMetadata( + let model = AIModelModel( id: id, dataCount: expenses.count, accuracy: nil, - createdAt: now - ) - - let model = AIModelModel( - id: metadata.id, - dataCount: metadata.dataCount, - accuracy: nil, - createdAt: metadata.createdAt + createdAt: now, + isSelected: true ) modelContext.insert(model) try? modelContext.save() - return .success(metadata) + 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 diff --git a/SpendLearning/SpendLearning/Source/Domain/Repositories/AIRepositoryProtocol.swift b/SpendLearning/SpendLearning/Source/Domain/Repositories/AIRepositoryProtocol.swift index e4508b3..47583e6 100644 --- a/SpendLearning/SpendLearning/Source/Domain/Repositories/AIRepositoryProtocol.swift +++ b/SpendLearning/SpendLearning/Source/Domain/Repositories/AIRepositoryProtocol.swift @@ -18,4 +18,6 @@ protocol AIRepositoryProtocol { 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/UseCases/AIUseCase.swift b/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCase.swift index f7c89c0..4cb3d8f 100644 --- a/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCase.swift +++ b/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCase.swift @@ -44,4 +44,8 @@ final class AIUseCase: AIUseCaseProtocol { 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 a3f08da..9d13c51 100644 --- a/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCaseProtocol.swift +++ b/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCaseProtocol.swift @@ -18,4 +18,6 @@ protocol AIUseCaseProtocol { 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/AIStatusCardView.swift b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIStatusCardView.swift index 7f5cc7e..d23d4c1 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIStatusCardView.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIStatusCardView.swift @@ -19,6 +19,7 @@ struct AIStatusCardView: View { let isCreatingModel: Bool let onCreateModel: () async -> Void let createModelError: AIModelCreationError? + let onSelectModel: (AIModelMetadata) async -> Void var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -51,7 +52,7 @@ struct AIStatusCardView: View { models: models, currentModelId: currentModel?.id ?? "" ) { selected in - print("선택된 모델: \(selected.id)") + Task { await onSelectModel(selected) } } } } diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIView.swift b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIView.swift index 8026420..6bf13b5 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIView.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIView.swift @@ -37,7 +37,10 @@ struct AIView: View { isShowingSuccessToast = true } }, - createModelError: viewModel.createModelError + createModelError: viewModel.createModelError, + onSelectModel: { selected in + await viewModel.selectModel(id: selected.id) + } ) } diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIViewModel.swift b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIViewModel.swift index 771f037..9234956 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIViewModel.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIViewModel.swift @@ -69,6 +69,11 @@ final class AIViewModel { isCreatingModel = false } + func selectModel(id: String) async { + await aiUseCase.selectModel(id: id) + await load() + } + // MARK: - Private private func load() async { let calendar = Calendar.current From 469e3d096892aa94535d754f2966923881a7781a Mon Sep 17 00:00:00 2001 From: snughnu Date: Wed, 15 Jul 2026 00:38:26 +0900 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20stub=20spy=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SpendLearning/SpendLearningTests/UseCases/AIUseCaseTests.swift | 2 ++ .../SpendLearningTests/ViewModels/AIViewModelTests.swift | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/SpendLearning/SpendLearningTests/UseCases/AIUseCaseTests.swift b/SpendLearning/SpendLearningTests/UseCases/AIUseCaseTests.swift index 590629a..a651060 100644 --- a/SpendLearning/SpendLearningTests/UseCases/AIUseCaseTests.swift +++ b/SpendLearning/SpendLearningTests/UseCases/AIUseCaseTests.swift @@ -176,6 +176,8 @@ final class SpyAIRepository: AIRepositoryProtocol { createModelCallCount += 1 return stubbedCreateModelResult } + + func selectModel(id: String) async {} } // MARK: - Stub diff --git a/SpendLearning/SpendLearningTests/ViewModels/AIViewModelTests.swift b/SpendLearning/SpendLearningTests/ViewModels/AIViewModelTests.swift index 23b683b..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() @@ -172,4 +172,5 @@ final class StubAIUseCase: AIUseCaseProtocol { 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 {} }