Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 11 additions & 11 deletions SpendLearning/SpendLearning.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -32,26 +32,24 @@
BCB415242FF52E7D00C9CABD /* Exceptions for "SpendLearning" folder in "SpendLearningTests" target */ = {
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,
Source/Domain/Entities/Expense/CalendarDay.swift,
Source/Domain/Entities/Expense/Category.swift,
Source/Domain/Entities/Expense/Expense.swift,
Source/Domain/Repositories/AIRepositoryProtocol.swift,
Source/Domain/Entities/Prediction/CategoryPrediction.swift,
Source/Domain/Entities/Prediction/CumulativePrediction.swift,
Source/Domain/Entities/Prediction/PredictionModelMetadata.swift,
Source/Domain/Repositories/CategoryRepositoryProtocol.swift,
Source/Domain/Repositories/ExpenseRepositoryProtocol.swift,
Source/Domain/UseCases/AIUseCase.swift,
Source/Domain/UseCases/AIUseCaseProtocol.swift,
Source/Domain/Repositories/PredictionRepositoryProtocol.swift,
Source/Domain/UseCases/CategoryUseCase.swift,
Source/Domain/UseCases/CategoryUseCaseProtocol.swift,
Source/Domain/UseCases/ExpenseUseCase.swift,
Source/Domain/UseCases/ExpenseUseCaseProtocol.swift,
Source/Presentation/View/AI/AIViewModel.swift,
Source/Domain/UseCases/PredictionUseCase.swift,
Source/Domain/UseCases/PredictionUseCaseProtocol.swift,
Source/Presentation/View/Home/HomeViewModel.swift,
Source/Presentation/View/NewExpense/NewExpenseViewModel.swift,
Source/Presentation/View/Prediction/PredictionViewModel.swift,
Source/Presentation/View/Settings/SettingsViewModel.swift,
);
target = BCB415072FF52D0700C9CABD /* SpendLearningTests */;
Expand Down Expand Up @@ -251,6 +249,7 @@
DEVELOPMENT_TEAM = RS49L5FVHY;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SpendLearning/Resource/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "소비학습";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
Expand All @@ -261,7 +260,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = com.sunghun.SpendLearning;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
Expand All @@ -288,6 +287,7 @@
DEVELOPMENT_TEAM = RS49L5FVHY;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SpendLearning/Resource/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "소비학습";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
Expand All @@ -298,7 +298,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = com.sunghun.SpendLearning;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"images" : [
{
"filename" : "appicon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
28 changes: 18 additions & 10 deletions SpendLearning/SpendLearning/Source/App/SceneDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,14 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
private func makeTabBarController() -> UITabBarController {
let container: ModelContainer
do {
container = try ModelContainer(for: ExpenseModel.self, CategoryModel.self, AIModelModel.self)
container = try ModelContainer(for: ExpenseModel.self, CategoryModel.self, PredictionModel.self)
} catch {
// SwiftData 마이그레이션 실패 시 메모리 전용 스토어로 폴백한다.
// 이 경로를 타면 기존에 저장된 사용자 데이터(지출/카테고리/예측 모델)가 이번 실행에서 보이지 않는다.
// 스키마 변경 후 이 로그가 찍히면 VersionedSchema/SchemaMigrationPlan 도입을 검토할 것.
print("⚠️ ModelContainer 생성 실패, 메모리 전용으로 폴백: \(error)")
container = try! ModelContainer(
for: ExpenseModel.self, CategoryModel.self, AIModelModel.self,
for: ExpenseModel.self, CategoryModel.self, PredictionModel.self,
configurations: ModelConfiguration(isStoredInMemoryOnly: true)
)
}
Expand All @@ -36,8 +40,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
let categoryRepository = SwiftDataCategoryRepository(modelContext: modelContext)
let categoryUseCase = CategoryUseCase(repository: categoryRepository)

let expenseRepository = MockExpenseRepository()
// let expenseRepository = SwiftDataExpenseRepository(modelContext: modelContext, categoryRepository: categoryRepository)
// let expenseRepository = MockExpenseRepository()
let expenseRepository = SwiftDataExpenseRepository(modelContext: modelContext, categoryRepository: categoryRepository)
let expenseUseCase = ExpenseUseCase(repository: expenseRepository)

let homeViewModel = HomeViewModel(expenseUseCase: expenseUseCase)
Expand All @@ -48,24 +52,28 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
selectedImage: UIImage(systemName: "house.fill")
)

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(
let predictionUseCase = PredictionUseCase(repository: SwiftDataPredictionRepository(modelContext: modelContext, expenseRepository: expenseRepository), expenseRepository: expenseRepository)
let predictionViewModel = PredictionViewModel(expenseUseCase: expenseUseCase, predictionUseCase: predictionUseCase)
let predictionViewController = UIHostingController(rootView: PredictionView(viewModel: predictionViewModel))
predictionViewController.tabBarItem = UITabBarItem(
title: "예측",
image: UIImage(systemName: "brain"),
selectedImage: UIImage(systemName: "brain.fill")
)

let settingsViewController = SettingsViewController(categoryUseCase: categoryUseCase)
let settingsViewController = SettingsViewController(
categoryUseCase: categoryUseCase,
expenseUseCase: expenseUseCase,
predictionUseCase: predictionUseCase
)
settingsViewController.tabBarItem = UITabBarItem(
title: "설정",
image: UIImage(systemName: "slider.horizontal.3"),
selectedImage: UIImage(systemName: "slider.horizontal.3")
)

let tabBar = UITabBarController()
tabBar.viewControllers = [homeViewController, aiViewController, settingsViewController]
tabBar.viewControllers = [homeViewController, predictionViewController, settingsViewController]
tabBar.tabBar.tintColor = .DesignSystem.accent

return tabBar
Expand Down
33 changes: 0 additions & 33 deletions SpendLearning/SpendLearning/Source/Data/Models/AIModelModel.swift

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
//
// PredictionModel.swift
// SpendLearning
//
// Created by 김성훈 on 7/14/26.
//

import Foundation
import SwiftData

@Model
final class PredictionModel {

var id: String
var dataCount: Int
var createdAt: Date
private var dailyPredictionsData: Data
private var categoryPredictionsData: Data

var dailyPredictions: [Int: Int] {
(try? JSONDecoder().decode([Int: Int].self, from: dailyPredictionsData)) ?? [:]
}

var categoryPredictions: [String: Int] {
(try? JSONDecoder().decode([String: Int].self, from: categoryPredictionsData)) ?? [:]
}

init(
id: String,
dataCount: Int,
createdAt: Date,
dailyPredictions: [Int: Int],
categoryPredictions: [String: Int]
) {
self.id = id
self.dataCount = dataCount
self.createdAt = createdAt
self.dailyPredictionsData = (try? JSONEncoder().encode(dailyPredictions)) ?? Data()
self.categoryPredictionsData = (try? JSONEncoder().encode(categoryPredictions)) ?? Data()
}

func update(dataCount: Int, createdAt: Date, dailyPredictions: [Int: Int], categoryPredictions: [String: Int]) {
self.dataCount = dataCount
self.createdAt = createdAt
self.dailyPredictionsData = (try? JSONEncoder().encode(dailyPredictions)) ?? Data()
self.categoryPredictionsData = (try? JSONEncoder().encode(categoryPredictions)) ?? Data()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
//
// StatisticsPredictor.swift
// SpendLearning
//
// Created by 김성훈 on 7/14/26.
//

import Foundation

final class StatisticsPredictor {

/// 패턴으로 신뢰하기 위한 최소 표본 수
private let minimumSampleCount = 2
/// 패턴으로 신뢰하기 위한 변동계수(표준편차/평균) 임계값. 낮을수록 값이 일관됨을 의미
private let coefficientOfVariationThreshold = 0.5

// MARK: - predictDaily

/// 이번 달 일별 예측 지출 금액을 반환한다.
/// 폴백 순서 (각 순위는 표본이 2건 이상이고 변동계수가 임계값 이하일 때만 채택한다):
/// 1. 과거 같은 날짜(dayOfMonth) 지출 평균 — 정기결제 등 날짜 기반 패턴
/// 2. 데이터가 패턴으로 인정되지 않으면 → 과거 같은 요일(weekday) 지출 평균 — 습관성 소비 패턴
/// 3. 그것도 패턴으로 인정되지 않으면 → 과거 월 평균 총액 / 이번 달 일수
func predictDaily(expenses: [Expense], year: Int, month: Int) async -> [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 dayOfMonth = calendar.component(.day, from: date)
let weekday = calendar.component(.weekday, from: date)

if let avg = reliableDayOfMonthAverage(expenses: pastExpenses, dayOfMonth: dayOfMonth) {
// 1순위: 같은 날짜의 일관된 평균 (정기결제 등)
result[day] = avg
} else if let avg = reliableWeekdayAverage(expenses: pastExpenses, weekday: weekday) {
// 2순위: 같은 요일의 일관된 평균 (습관성 소비)
result[day] = avg
} else {
// 3순위: 월 평균 총액 / 일수
result[day] = monthlyTotal / dayRange.count
}
}
return result
}

// MARK: - predictCategory

/// 이번 달 카테고리별 예측 지출 금액을 반환한다.
/// 과거 카테고리별 월 평균을 계산해 반환한다.
func predictCategory(expenses: [Expense], year: Int, month: Int) async -> [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 (predictDaily)

/// 같은 날짜(dayOfMonth)의 지출을 월별로 합산해, 표본이 충분하고 변동계수가 임계값 이하일 때만 평균을 반환한다.
private func reliableDayOfMonthAverage(expenses: [Expense], dayOfMonth: Int) -> Int? {
let calendar = Calendar.current
let matched = expenses.filter {
calendar.component(.day, from: $0.date) == dayOfMonth
}
let monthlyTotals = monthlyTotals(of: matched, calendar: calendar)
return reliableAverage(of: monthlyTotals)
}

/// 같은 요일(weekday)의 지출을 주별로 합산해, 표본이 충분하고 변동계수가 임계값 이하일 때만 평균을 반환한다.
private func reliableWeekdayAverage(expenses: [Expense], weekday: Int) -> Int? {
let calendar = Calendar.current
let matched = expenses.filter {
calendar.component(.weekday, from: $0.date) == weekday
}
let weeklyTotals = weeklyTotals(of: matched, calendar: calendar)
return reliableAverage(of: weeklyTotals)
}

/// 지출을 연-월 단위로 묶어 각 달의 합계 목록을 반환한다.
private func monthlyTotals(of expenses: [Expense], calendar: Calendar) -> [Int] {
Dictionary(grouping: expenses) {
"\(calendar.component(.year, from: $0.date))-\(calendar.component(.month, from: $0.date))"
}
.values
.map { group in group.reduce(0) { $0 + $1.amount } }
}

/// 지출을 연-주 단위로 묶어 각 주의 합계 목록을 반환한다.
private func weeklyTotals(of expenses: [Expense], calendar: Calendar) -> [Int] {
Dictionary(grouping: expenses) {
"\(calendar.component(.year, from: $0.date))-\(calendar.component(.weekOfYear, from: $0.date))"
}
.values
.map { group in group.reduce(0) { $0 + $1.amount } }
}

/// 표본이 최소 개수 이상이고, 변동계수가 임계값 이하로 일관될 때만 평균을 반환한다.
/// 표본이 부족하거나(우연 배제) 값이 들쭉날쭉하면(패턴 아님) nil을 반환해 다음 순위로 폴백시킨다.
private func reliableAverage(of samples: [Int]) -> Int? {
guard samples.count >= minimumSampleCount else { return nil }

let mean = Double(samples.reduce(0, +)) / Double(samples.count)
guard mean > 0 else { return nil }

let variance = samples.reduce(0.0) { $0 + pow(Double($1) - mean, 2) } / Double(samples.count)
let standardDeviation = variance.squareRoot()
let coefficientOfVariation = standardDeviation / mean

guard coefficientOfVariation <= coefficientOfVariationThreshold else { return nil }

return Int(mean)
}

/// 과거 데이터의 월 평균 총 지출
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)
}
}
Loading
Loading