diff --git a/MLTraining/README.md b/MLTraining/README.md new file mode 100644 index 0000000..89f7e6b --- /dev/null +++ b/MLTraining/README.md @@ -0,0 +1,11 @@ +# MLTraining + +CategoryClassifier.mlmodel을 생성하는 1회성 Python 스크립트. +앱 빌드에 포함되지 않음 — 모델을 새로 만들거나 시드 데이터를 늘릴 때만 수동 실행. + +## 실행 +\`\`\` +python3 train.py +\`\`\` + +생성된 `CategoryClassifier.mlmodel`을 `SpendLearning/SpendLearning/Resource/`로 복사하면 Xcode가 자동 인식. diff --git a/MLTraining/seed_data.json b/MLTraining/seed_data.json new file mode 100644 index 0000000..25c9827 --- /dev/null +++ b/MLTraining/seed_data.json @@ -0,0 +1,2002 @@ +[ + { + "memo": "스타벅스", + "category": "카페/간식" + }, + { + "memo": "이디야커피", + "category": "카페/간식" + }, + { + "memo": "빽다방", + "category": "카페/간식" + }, + { + "memo": "메가커피", + "category": "카페/간식" + }, + { + "memo": "투썸플레이스", + "category": "카페/간식" + }, + { + "memo": "컴포즈커피", + "category": "카페/간식" + }, + { + "memo": "폴바셋", + "category": "카페/간식" + }, + { + "memo": "배스킨라빈스", + "category": "카페/간식" + }, + { + "memo": "설빙", + "category": "카페/간식" + }, + { + "memo": "공차", + "category": "카페/간식" + }, + { + "memo": "할리스커피", + "category": "카페/간식" + }, + { + "memo": "커피빈", + "category": "카페/간식" + }, + { + "memo": "탐앤탐스", + "category": "카페/간식" + }, + { + "memo": "엔제리너스", + "category": "카페/간식" + }, + { + "memo": "요거프레소", + "category": "카페/간식" + }, + { + "memo": "더벤티", + "category": "카페/간식" + }, + { + "memo": "매머드커피", + "category": "카페/간식" + }, + { + "memo": "파스쿠찌", + "category": "카페/간식" + }, + { + "memo": "블루보틀", + "category": "카페/간식" + }, + { + "memo": "던킨도너츠", + "category": "카페/간식" + }, + { + "memo": "크리스피크림도넛", + "category": "카페/간식" + }, + { + "memo": "빙수집", + "category": "카페/간식" + }, + { + "memo": "붕어빵", + "category": "카페/간식" + }, + { + "memo": "타로", + "category": "카페/간식" + }, + { + "memo": "쥬씨", + "category": "카페/간식" + }, + { + "memo": "스무디킹", + "category": "카페/간식" + }, + { + "memo": "베스킨라빈스31", + "category": "카페/간식" + }, + { + "memo": "나뚜루", + "category": "카페/간식" + }, + { + "memo": "다연", + "category": "카페/간식" + }, + { + "memo": "빵집", + "category": "카페/간식" + }, + { + "memo": "파리바게뜨", + "category": "카페/간식" + }, + { + "memo": "뚜레쥬르", + "category": "카페/간식" + }, + { + "memo": "성심당", + "category": "카페/간식" + }, + { + "memo": "매쓰커피", + "category": "카페/간식" + }, + { + "memo": "카페베네", + "category": "카페/간식" + }, + { + "memo": "커피나무", + "category": "카페/간식" + }, + { + "memo": "이삭토스트", + "category": "카페/간식" + }, + { + "memo": "코너스톤커피", + "category": "카페/간식" + }, + { + "memo": "몽슈슈", + "category": "카페/간식" + }, + { + "memo": "와플대학", + "category": "카페/간식" + }, + { + "memo": "아이스크림할인점", + "category": "카페/간식" + }, + { + "memo": "떡볶이집", + "category": "카페/간식" + }, + { + "memo": "붕어싸만코", + "category": "카페/간식" + }, + { + "memo": "커피에반하다", + "category": "카페/간식" + }, + { + "memo": "카페드파리", + "category": "카페/간식" + }, + { + "memo": "얼음골빙수", + "category": "카페/간식" + }, + { + "memo": "미스터도넛", + "category": "카페/간식" + }, + { + "memo": "빽다방커피", + "category": "카페/간식" + }, + { + "memo": "카페공드리", + "category": "카페/간식" + }, + { + "memo": "달콤커피", + "category": "카페/간식" + }, + { + "memo": "김밥천국", + "category": "식비" + }, + { + "memo": "맥도날드", + "category": "식비" + }, + { + "memo": "배달의민족", + "category": "식비" + }, + { + "memo": "이마트", + "category": "식비" + }, + { + "memo": "국밥집", + "category": "식비" + }, + { + "memo": "burger king", + "category": "식비" + }, + { + "memo": "본죽", + "category": "식비" + }, + { + "memo": "교촌치킨", + "category": "식비" + }, + { + "memo": "홈플러스", + "category": "식비" + }, + { + "memo": "요기요", + "category": "식비" + }, + { + "memo": "쿠팡이츠", + "category": "식비" + }, + { + "memo": "롯데리아", + "category": "식비" + }, + { + "memo": "버거킹", + "category": "식비" + }, + { + "memo": "맘스터치", + "category": "식비" + }, + { + "memo": "bhc치킨", + "category": "식비" + }, + { + "memo": "굽네치킨", + "category": "식비" + }, + { + "memo": "네네치킨", + "category": "식비" + }, + { + "memo": "노브랜드버거", + "category": "식비" + }, + { + "memo": "김가네", + "category": "식비" + }, + { + "memo": "본가", + "category": "식비" + }, + { + "memo": "명륜진사갈비", + "category": "식비" + }, + { + "memo": "한솥도시락", + "category": "식비" + }, + { + "memo": "역전할머니맥주", + "category": "식비" + }, + { + "memo": "농협하나로마트", + "category": "식비" + }, + { + "memo": "GS25", + "category": "식비" + }, + { + "memo": "CU편의점", + "category": "식비" + }, + { + "memo": "세븐일레븐", + "category": "식비" + }, + { + "memo": "이마트24", + "category": "식비" + }, + { + "memo": "롯데마트", + "category": "식비" + }, + { + "memo": "코스트코", + "category": "식비" + }, + { + "memo": "traders", + "category": "식비" + }, + { + "memo": "설렁탕집", + "category": "식비" + }, + { + "memo": "냉면집", + "category": "식비" + }, + { + "memo": "짜장면집", + "category": "식비" + }, + { + "memo": "중국집", + "category": "식비" + }, + { + "memo": "초밥집", + "category": "식비" + }, + { + "memo": "돈까스집", + "category": "식비" + }, + { + "memo": "파스타집", + "category": "식비" + }, + { + "memo": "피자헛", + "category": "식비" + }, + { + "memo": "도미노피자", + "category": "식비" + }, + { + "memo": "미스터피자", + "category": "식비" + }, + { + "memo": "곱창집", + "category": "식비" + }, + { + "memo": "족발집", + "category": "식비" + }, + { + "memo": "보쌈집", + "category": "식비" + }, + { + "memo": "삼겹살집", + "category": "식비" + }, + { + "memo": "이자카야", + "category": "식비" + }, + { + "memo": "포장마차", + "category": "식비" + }, + { + "memo": "분식집", + "category": "식비" + }, + { + "memo": "샐러디", + "category": "식비" + }, + { + "memo": "서브웨이", + "category": "식비" + }, + { + "memo": "지하철", + "category": "교통" + }, + { + "memo": "택시", + "category": "교통" + }, + { + "memo": "카카오T", + "category": "교통" + }, + { + "memo": "버스", + "category": "교통" + }, + { + "memo": "SRT", + "category": "교통" + }, + { + "memo": "KTX", + "category": "교통" + }, + { + "memo": "티머니", + "category": "교통" + }, + { + "memo": "쏘카", + "category": "교통" + }, + { + "memo": "고속버스", + "category": "교통" + }, + { + "memo": "주차장", + "category": "교통" + }, + { + "memo": "그린카", + "category": "교통" + }, + { + "memo": "우버택시", + "category": "교통" + }, + { + "memo": "타다", + "category": "교통" + }, + { + "memo": "코레일", + "category": "교통" + }, + { + "memo": "공항버스", + "category": "교통" + }, + { + "memo": "리무진버스", + "category": "교통" + }, + { + "memo": "톨게이트", + "category": "교통" + }, + { + "memo": "주유소", + "category": "교통" + }, + { + "memo": "GS칼텍스", + "category": "교통" + }, + { + "memo": "SK주유소", + "category": "교통" + }, + { + "memo": "S OIL", + "category": "교통" + }, + { + "memo": "현대오일뱅크", + "category": "교통" + }, + { + "memo": "고속도로통행료", + "category": "교통" + }, + { + "memo": "따릉이", + "category": "교통" + }, + { + "memo": "카카오바이크", + "category": "교통" + }, + { + "memo": "전기차충전", + "category": "교통" + }, + { + "memo": "마을버스", + "category": "교통" + }, + { + "memo": "공항철도", + "category": "교통" + }, + { + "memo": "신분당선", + "category": "교통" + }, + { + "memo": "제주항공", + "category": "교통" + }, + { + "memo": "대한항공", + "category": "교통" + }, + { + "memo": "아시아나항공", + "category": "교통" + }, + { + "memo": "진에어", + "category": "교통" + }, + { + "memo": "티웨이항공", + "category": "교통" + }, + { + "memo": "렌터카", + "category": "교통" + }, + { + "memo": "롯데렌터카", + "category": "교통" + }, + { + "memo": "SK렌터카", + "category": "교통" + }, + { + "memo": "발렛파킹", + "category": "교통" + }, + { + "memo": "여객선", + "category": "교통" + }, + { + "memo": "케이블카", + "category": "교통" + }, + { + "memo": "자동차보험", + "category": "교통" + }, + { + "memo": "자동차정비소", + "category": "교통" + }, + { + "memo": "세차장", + "category": "교통" + }, + { + "memo": "타이어뱅크", + "category": "교통" + }, + { + "memo": "김포공항", + "category": "교통" + }, + { + "memo": "인천공항", + "category": "교통" + }, + { + "memo": "택시비", + "category": "교통" + }, + { + "memo": "버스비", + "category": "교통" + }, + { + "memo": "지하철비", + "category": "교통" + }, + { + "memo": "하이패스", + "category": "교통" + }, + { + "memo": "쿠팡", + "category": "쇼핑" + }, + { + "memo": "무신사", + "category": "쇼핑" + }, + { + "memo": "올리브영", + "category": "쇼핑" + }, + { + "memo": "다이소", + "category": "쇼핑" + }, + { + "memo": "지마켓", + "category": "쇼핑" + }, + { + "memo": "11번가", + "category": "쇼핑" + }, + { + "memo": "유니클로", + "category": "쇼핑" + }, + { + "memo": "나이키", + "category": "쇼핑" + }, + { + "memo": "에이블리", + "category": "쇼핑" + }, + { + "memo": "당근마켓", + "category": "쇼핑" + }, + { + "memo": "옥션", + "category": "쇼핑" + }, + { + "memo": "인터파크", + "category": "쇼핑" + }, + { + "memo": "티몬", + "category": "쇼핑" + }, + { + "memo": "위메프", + "category": "쇼핑" + }, + { + "memo": "SSG닷컴", + "category": "쇼핑" + }, + { + "memo": "롯데온", + "category": "쇼핑" + }, + { + "memo": "아디다스", + "category": "쇼핑" + }, + { + "memo": "자라", + "category": "쇼핑" + }, + { + "memo": "H&M", + "category": "쇼핑" + }, + { + "memo": "지그재그", + "category": "쇼핑" + }, + { + "memo": "브랜디", + "category": "쇼핑" + }, + { + "memo": "무인양품", + "category": "쇼핑" + }, + { + "memo": "이케아", + "category": "쇼핑" + }, + { + "memo": "노브랜드", + "category": "쇼핑" + }, + { + "memo": "삼성디지털프라자", + "category": "쇼핑" + }, + { + "memo": "하이마트", + "category": "쇼핑" + }, + { + "memo": "애플스토어", + "category": "쇼핑" + }, + { + "memo": "교보문고", + "category": "쇼핑" + }, + { + "memo": "영풍문고", + "category": "쇼핑" + }, + { + "memo": "알라딘중고서점", + "category": "쇼핑" + }, + { + "memo": "예스24", + "category": "쇼핑" + }, + { + "memo": "롯데백화점", + "category": "쇼핑" + }, + { + "memo": "신세계백화점", + "category": "쇼핑" + }, + { + "memo": "현대백화점", + "category": "쇼핑" + }, + { + "memo": "코오롱스포츠", + "category": "쇼핑" + }, + { + "memo": "노스페이스", + "category": "쇼핑" + }, + { + "memo": "뉴발란스", + "category": "쇼핑" + }, + { + "memo": "폴로", + "category": "쇼핑" + }, + { + "memo": "탑텐", + "category": "쇼핑" + }, + { + "memo": "스파오", + "category": "쇼핑" + }, + { + "memo": "이랜드", + "category": "쇼핑" + }, + { + "memo": "다나와", + "category": "쇼핑" + }, + { + "memo": "번개장터", + "category": "쇼핑" + }, + { + "memo": "중고나라", + "category": "쇼핑" + }, + { + "memo": "오늘의집", + "category": "쇼핑" + }, + { + "memo": "화장품가게", + "category": "쇼핑" + }, + { + "memo": "아이오페", + "category": "쇼핑" + }, + { + "memo": "이니스프리", + "category": "쇼핑" + }, + { + "memo": "미샤", + "category": "쇼핑" + }, + { + "memo": "네이버쇼핑", + "category": "쇼핑" + }, + { + "memo": "CGV", + "category": "여가" + }, + { + "memo": "롯데시네마", + "category": "여가" + }, + { + "memo": "PC방", + "category": "여가" + }, + { + "memo": "노래방", + "category": "여가" + }, + { + "memo": "메가박스", + "category": "여가" + }, + { + "memo": "볼링장", + "category": "여가" + }, + { + "memo": "당구장", + "category": "여가" + }, + { + "memo": "골프연습장", + "category": "여가" + }, + { + "memo": "놀이공원", + "category": "여가" + }, + { + "memo": "전시회", + "category": "여가" + }, + { + "memo": "에버랜드", + "category": "여가" + }, + { + "memo": "롯데월드", + "category": "여가" + }, + { + "memo": "워터파크", + "category": "여가" + }, + { + "memo": "찜질방", + "category": "여가" + }, + { + "memo": "사우나", + "category": "여가" + }, + { + "memo": "스크린골프", + "category": "여가" + }, + { + "memo": "방탈출카페", + "category": "여가" + }, + { + "memo": "보드게임카페", + "category": "여가" + }, + { + "memo": "만화카페", + "category": "여가" + }, + { + "memo": "동물원", + "category": "여가" + }, + { + "memo": "아쿠아리움", + "category": "여가" + }, + { + "memo": "박물관", + "category": "여가" + }, + { + "memo": "미술관", + "category": "여가" + }, + { + "memo": "콘서트", + "category": "여가" + }, + { + "memo": "뮤지컬", + "category": "여가" + }, + { + "memo": "연극", + "category": "여가" + }, + { + "memo": "야구장", + "category": "여가" + }, + { + "memo": "축구장", + "category": "여가" + }, + { + "memo": "낚시터", + "category": "여가" + }, + { + "memo": "캠핑장", + "category": "여가" + }, + { + "memo": "펜션", + "category": "여가" + }, + { + "memo": "호텔", + "category": "여가" + }, + { + "memo": "리조트", + "category": "여가" + }, + { + "memo": "에어비앤비", + "category": "여가" + }, + { + "memo": "야놀자", + "category": "여가" + }, + { + "memo": "여기어때", + "category": "여가" + }, + { + "memo": "스키장", + "category": "여가" + }, + { + "memo": "수영장", + "category": "여가" + }, + { + "memo": "클라이밍장", + "category": "여가" + }, + { + "memo": "탁구장", + "category": "여가" + }, + { + "memo": "테니스장", + "category": "여가" + }, + { + "memo": "게임방", + "category": "여가" + }, + { + "memo": "오락실", + "category": "여가" + }, + { + "memo": "코인노래방", + "category": "여가" + }, + { + "memo": "VR방", + "category": "여가" + }, + { + "memo": "다트바", + "category": "여가" + }, + { + "memo": "포켓볼장", + "category": "여가" + }, + { + "memo": "실내낚시터", + "category": "여가" + }, + { + "memo": "루지체험장", + "category": "여가" + }, + { + "memo": "온천", + "category": "여가" + }, + { + "memo": "SKT", + "category": "통신비" + }, + { + "memo": "KT", + "category": "통신비" + }, + { + "memo": "LG유플러스", + "category": "통신비" + }, + { + "memo": "알뜰폰", + "category": "통신비" + }, + { + "memo": "핸드폰요금", + "category": "통신비" + }, + { + "memo": "인터넷요금", + "category": "통신비" + }, + { + "memo": "휴대폰보험", + "category": "통신비" + }, + { + "memo": "통신요금", + "category": "통신비" + }, + { + "memo": "SK텔레콤", + "category": "통신비" + }, + { + "memo": "케이티", + "category": "통신비" + }, + { + "memo": "엘지유플러스", + "category": "통신비" + }, + { + "memo": "우체국알뜰폰", + "category": "통신비" + }, + { + "memo": "KT M모바일", + "category": "통신비" + }, + { + "memo": "헬로모바일", + "category": "통신비" + }, + { + "memo": "U+유모바일", + "category": "통신비" + }, + { + "memo": "스카이라이프", + "category": "통신비" + }, + { + "memo": "IPTV요금", + "category": "통신비" + }, + { + "memo": "와이파이요금", + "category": "통신비" + }, + { + "memo": "공유기요금", + "category": "통신비" + }, + { + "memo": "데이터요금", + "category": "통신비" + }, + { + "memo": "통신사멤버십", + "category": "통신비" + }, + { + "memo": "휴대폰수리비", + "category": "통신비" + }, + { + "memo": "휴대폰액정수리", + "category": "통신비" + }, + { + "memo": "유심비", + "category": "통신비" + }, + { + "memo": "기기변경할부금", + "category": "통신비" + }, + { + "memo": "핸드폰할부금", + "category": "통신비" + }, + { + "memo": "케이블방송료", + "category": "통신비" + }, + { + "memo": "KT인터넷", + "category": "통신비" + }, + { + "memo": "SK브로드밴드", + "category": "통신비" + }, + { + "memo": "LG헬로비전", + "category": "통신비" + }, + { + "memo": "국제전화요금", + "category": "통신비" + }, + { + "memo": "로밍요금", + "category": "통신비" + }, + { + "memo": "번호이동", + "category": "통신비" + }, + { + "memo": "선불유심", + "category": "통신비" + }, + { + "memo": "eSIM", + "category": "통신비" + }, + { + "memo": "핸드폰케이스구매", + "category": "통신비" + }, + { + "memo": "충전기구매", + "category": "통신비" + }, + { + "memo": "핸드폰필름", + "category": "통신비" + }, + { + "memo": "통신비자동이체", + "category": "통신비" + }, + { + "memo": "모바일요금제", + "category": "통신비" + }, + { + "memo": "가족결합할인", + "category": "통신비" + }, + { + "memo": "인터넷설치비", + "category": "통신비" + }, + { + "memo": "셋톱박스요금", + "category": "통신비" + }, + { + "memo": "공기계구매", + "category": "통신비" + }, + { + "memo": "휴대폰배터리교체", + "category": "통신비" + }, + { + "memo": "통신사위약금", + "category": "통신비" + }, + { + "memo": "부가서비스요금", + "category": "통신비" + }, + { + "memo": "테더링요금", + "category": "통신비" + }, + { + "memo": "쓰리모바일", + "category": "통신비" + }, + { + "memo": "프리티", + "category": "통신비" + }, + { + "memo": "약국", + "category": "의료/건강" + }, + { + "memo": "병원", + "category": "의료/건강" + }, + { + "memo": "한의원", + "category": "의료/건강" + }, + { + "memo": "헬스장", + "category": "의료/건강" + }, + { + "memo": "치과", + "category": "의료/건강" + }, + { + "memo": "정형외과", + "category": "의료/건강" + }, + { + "memo": "필라테스", + "category": "의료/건강" + }, + { + "memo": "요가원", + "category": "의료/건강" + }, + { + "memo": "건강검진", + "category": "의료/건강" + }, + { + "memo": "안경점", + "category": "의료/건강" + }, + { + "memo": "피부과", + "category": "의료/건강" + }, + { + "memo": "성형외과", + "category": "의료/건강" + }, + { + "memo": "안과", + "category": "의료/건강" + }, + { + "memo": "이비인후과", + "category": "의료/건강" + }, + { + "memo": "산부인과", + "category": "의료/건강" + }, + { + "memo": "소아과", + "category": "의료/건강" + }, + { + "memo": "내과", + "category": "의료/건강" + }, + { + "memo": "정신건강의학과", + "category": "의료/건강" + }, + { + "memo": "재활의학과", + "category": "의료/건강" + }, + { + "memo": "물리치료실", + "category": "의료/건강" + }, + { + "memo": "동물병원", + "category": "의료/건강" + }, + { + "memo": "건강보조식품", + "category": "의료/건강" + }, + { + "memo": "영양제구매", + "category": "의료/건강" + }, + { + "memo": "종합비타민", + "category": "의료/건강" + }, + { + "memo": "마스크구매", + "category": "의료/건강" + }, + { + "memo": "체육관", + "category": "의료/건강" + }, + { + "memo": "크로스핏", + "category": "의료/건강" + }, + { + "memo": "PT비용", + "category": "의료/건강" + }, + { + "memo": "발마사지", + "category": "의료/건강" + }, + { + "memo": "마사지샵", + "category": "의료/건강" + }, + { + "memo": "한약방", + "category": "의료/건강" + }, + { + "memo": "침술원", + "category": "의료/건강" + }, + { + "memo": "정신과상담료", + "category": "의료/건강" + }, + { + "memo": "심리상담센터", + "category": "의료/건강" + }, + { + "memo": "임플란트비용", + "category": "의료/건강" + }, + { + "memo": "스케일링비용", + "category": "의료/건강" + }, + { + "memo": "MRI검사비", + "category": "의료/건강" + }, + { + "memo": "엑스레이검사비", + "category": "의료/건강" + }, + { + "memo": "예방접종비", + "category": "의료/건강" + }, + { + "memo": "독감주사비", + "category": "의료/건강" + }, + { + "memo": "약국처방전", + "category": "의료/건강" + }, + { + "memo": "콘택트렌즈구매", + "category": "의료/건강" + }, + { + "memo": "보청기구매", + "category": "의료/건강" + }, + { + "memo": "다이어트한약", + "category": "의료/건강" + }, + { + "memo": "산후조리원", + "category": "의료/건강" + }, + { + "memo": "건강기능식품", + "category": "의료/건강" + }, + { + "memo": "혈압측정기구매", + "category": "의료/건강" + }, + { + "memo": "체중계구매", + "category": "의료/건강" + }, + { + "memo": "구급약구매", + "category": "의료/건강" + }, + { + "memo": "온누리약국", + "category": "의료/건강" + }, + { + "memo": "넷플릭스", + "category": "구독" + }, + { + "memo": "유튜브프리미엄", + "category": "구독" + }, + { + "memo": "멜론", + "category": "구독" + }, + { + "memo": "왓챠", + "category": "구독" + }, + { + "memo": "디즈니플러스", + "category": "구독" + }, + { + "memo": "스포티파이", + "category": "구독" + }, + { + "memo": "쿠팡플레이", + "category": "구독" + }, + { + "memo": "웨이브", + "category": "구독" + }, + { + "memo": "밀리의서재", + "category": "구독" + }, + { + "memo": "네이버플러스", + "category": "구독" + }, + { + "memo": "티빙", + "category": "구독" + }, + { + "memo": "지니뮤직", + "category": "구독" + }, + { + "memo": "플로", + "category": "구독" + }, + { + "memo": "애플뮤직", + "category": "구독" + }, + { + "memo": "애플TV플러스", + "category": "구독" + }, + { + "memo": "유데미", + "category": "구독" + }, + { + "memo": "클래스101", + "category": "구독" + }, + { + "memo": "리디셀렉트", + "category": "구독" + }, + { + "memo": "윌라오디오북", + "category": "구독" + }, + { + "memo": "노션플러스", + "category": "구독" + }, + { + "memo": "챗지피티구독", + "category": "구독" + }, + { + "memo": "클로드구독", + "category": "구독" + }, + { + "memo": "아이클라우드용량", + "category": "구독" + }, + { + "memo": "구글원", + "category": "구독" + }, + { + "memo": "드롭박스구독", + "category": "구독" + }, + { + "memo": "어도비구독", + "category": "구독" + }, + { + "memo": "마이크로소프트365", + "category": "구독" + }, + { + "memo": "쿠팡와우멤버십", + "category": "구독" + }, + { + "memo": "네이버웹툰쿠키", + "category": "구독" + }, + { + "memo": "카카오웹툰이용권", + "category": "구독" + }, + { + "memo": "리멤버프로", + "category": "구독" + }, + { + "memo": "토스프라임", + "category": "구독" + }, + { + "memo": "옥수수구독", + "category": "구독" + }, + { + "memo": "지식iN구독", + "category": "구독" + }, + { + "memo": "밴드팬클럽구독", + "category": "구독" + }, + { + "memo": "온라인클래스구독", + "category": "구독" + }, + { + "memo": "신문구독료", + "category": "구독" + }, + { + "memo": "조선일보구독", + "category": "구독" + }, + { + "memo": "동아일보구독", + "category": "구독" + }, + { + "memo": "우유구독", + "category": "구독" + }, + { + "memo": "정기배송서비스", + "category": "구독" + }, + { + "memo": "꽃구독서비스", + "category": "구독" + }, + { + "memo": "면도날정기구독", + "category": "구독" + }, + { + "memo": "생수정기배송", + "category": "구독" + }, + { + "memo": "쿠팡로켓와우", + "category": "구독" + }, + { + "memo": "밀키트정기구독", + "category": "구독" + }, + { + "memo": "반찬정기배송", + "category": "구독" + }, + { + "memo": "게임패스구독", + "category": "구독" + }, + { + "memo": "플레이스테이션플러스", + "category": "구독" + }, + { + "memo": "닌텐도온라인", + "category": "구독" + }, + { + "memo": "축의금", + "category": "경조사" + }, + { + "memo": "부의금", + "category": "경조사" + }, + { + "memo": "화환", + "category": "경조사" + }, + { + "memo": "돌잔치", + "category": "경조사" + }, + { + "memo": "생일선물", + "category": "경조사" + }, + { + "memo": "결혼선물", + "category": "경조사" + }, + { + "memo": "조의금", + "category": "경조사" + }, + { + "memo": "명절용돈", + "category": "경조사" + }, + { + "memo": "개업축하", + "category": "경조사" + }, + { + "memo": "부조금", + "category": "경조사" + }, + { + "memo": "결혼식축의금", + "category": "경조사" + }, + { + "memo": "장례식조의금", + "category": "경조사" + }, + { + "memo": "돌잔치선물", + "category": "경조사" + }, + { + "memo": "백일잔치", + "category": "경조사" + }, + { + "memo": "환갑잔치", + "category": "경조사" + }, + { + "memo": "칠순잔치", + "category": "경조사" + }, + { + "memo": "입학선물", + "category": "경조사" + }, + { + "memo": "졸업선물", + "category": "경조사" + }, + { + "memo": "취업축하선물", + "category": "경조사" + }, + { + "memo": "승진축하선물", + "category": "경조사" + }, + { + "memo": "이사선물", + "category": "경조사" + }, + { + "memo": "집들이선물", + "category": "경조사" + }, + { + "memo": "명절선물세트", + "category": "경조사" + }, + { + "memo": "추석선물", + "category": "경조사" + }, + { + "memo": "설날세뱃돈", + "category": "경조사" + }, + { + "memo": "어버이날선물", + "category": "경조사" + }, + { + "memo": "스승의날선물", + "category": "경조사" + }, + { + "memo": "화이트데이선물", + "category": "경조사" + }, + { + "memo": "발렌타인데이선물", + "category": "경조사" + }, + { + "memo": "크리스마스선물", + "category": "경조사" + }, + { + "memo": "출산축하선물", + "category": "경조사" + }, + { + "memo": "돌잔치답례품", + "category": "경조사" + }, + { + "memo": "결혼식답례품", + "category": "경조사" + }, + { + "memo": "제사비용", + "category": "경조사" + }, + { + "memo": "성묘비용", + "category": "경조사" + }, + { + "memo": "산소벌초비용", + "category": "경조사" + }, + { + "memo": "화장장비용", + "category": "경조사" + }, + { + "memo": "상조회비", + "category": "경조사" + }, + { + "memo": "종교헌금", + "category": "경조사" + }, + { + "memo": "십일조", + "category": "경조사" + }, + { + "memo": "동창회비", + "category": "경조사" + }, + { + "memo": "계모임회비", + "category": "경조사" + }, + { + "memo": "회사경조사비", + "category": "경조사" + }, + { + "memo": "부서회식경조비", + "category": "경조사" + }, + { + "memo": "친구결혼축의금", + "category": "경조사" + }, + { + "memo": "직장동료축의금", + "category": "경조사" + }, + { + "memo": "은사님선물", + "category": "경조사" + }, + { + "memo": "회갑선물", + "category": "경조사" + }, + { + "memo": "돌잡이용품구매", + "category": "경조사" + }, + { + "memo": "장례용품구매", + "category": "경조사" + }, + { + "memo": "기타지출", + "category": "기타" + }, + { + "memo": "잡비", + "category": "기타" + }, + { + "memo": "현금인출", + "category": "기타" + }, + { + "memo": "기부", + "category": "기타" + }, + { + "memo": "분실물", + "category": "기타" + }, + { + "memo": "벌금", + "category": "기타" + }, + { + "memo": "수수료", + "category": "기타" + }, + { + "memo": "기타결제", + "category": "기타" + }, + { + "memo": "미분류", + "category": "기타" + }, + { + "memo": "잡화점", + "category": "기타" + }, + { + "memo": "은행수수료", + "category": "기타" + }, + { + "memo": "송금수수료", + "category": "기타" + }, + { + "memo": "ATM수수료", + "category": "기타" + }, + { + "memo": "환전수수료", + "category": "기타" + }, + { + "memo": "주차위반과태료", + "category": "기타" + }, + { + "memo": "속도위반과태료", + "category": "기타" + }, + { + "memo": "세금", + "category": "기타" + }, + { + "memo": "자동차세", + "category": "기타" + }, + { + "memo": "재산세", + "category": "기타" + }, + { + "memo": "종합소득세", + "category": "기타" + }, + { + "memo": "복권구매", + "category": "기타" + }, + { + "memo": "로또구매", + "category": "기타" + }, + { + "memo": "동전기부함", + "category": "기타" + }, + { + "memo": "유니세프후원", + "category": "기타" + }, + { + "memo": "월드비전후원", + "category": "기타" + }, + { + "memo": "분실카드재발급비", + "category": "기타" + }, + { + "memo": "여권발급비", + "category": "기타" + }, + { + "memo": "증명서발급비", + "category": "기타" + }, + { + "memo": "인감증명수수료", + "category": "기타" + }, + { + "memo": "공증수수료", + "category": "기타" + }, + { + "memo": "변호사상담료", + "category": "기타" + }, + { + "memo": "세무사수수료", + "category": "기타" + }, + { + "memo": "택배비", + "category": "기타" + }, + { + "memo": "우편요금", + "category": "기타" + }, + { + "memo": "등기우편비", + "category": "기타" + }, + { + "memo": "복사비", + "category": "기타" + }, + { + "memo": "인쇄비", + "category": "기타" + }, + { + "memo": "코인노래방잔돈", + "category": "기타" + }, + { + "memo": "자판기음료", + "category": "기타" + }, + { + "memo": "동전세탁방", + "category": "기타" + }, + { + "memo": "정수기렌탈료", + "category": "기타" + }, + { + "memo": "비데렌탈료", + "category": "기타" + }, + { + "memo": "안마의자렌탈료", + "category": "기타" + }, + { + "memo": "정기적금", + "category": "기타" + }, + { + "memo": "적립식펀드", + "category": "기타" + }, + { + "memo": "용돈", + "category": "기타" + }, + { + "memo": "이체수수료", + "category": "기타" + }, + { + "memo": "연체료", + "category": "기타" + }, + { + "memo": "카드연회비", + "category": "기타" + }, + { + "memo": "보관료", + "category": "기타" + } +] diff --git a/MLTraining/train.py b/MLTraining/train.py new file mode 100644 index 0000000..c8871c2 --- /dev/null +++ b/MLTraining/train.py @@ -0,0 +1,101 @@ +import json +import os +from collections import Counter + +from coremltools.models import MLModel +from coremltools.models.nearest_neighbors import KNearestNeighborsClassifierBuilder + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +SEED_DATA_PATH = os.path.join(SCRIPT_DIR, "seed_data.json") +VOCAB_OUTPUT_PATH = os.path.join(SCRIPT_DIR, "vocabulary.json") +MODEL_OUTPUT_PATH = os.path.join(SCRIPT_DIR, "CategoryClassifier.mlmodel") + +DEFAULT_CATEGORY = "기타" + +CHOSUNG = list("ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ") +JUNGSUNG = list("ㅏㅐㅑㅒㅓㅔㅕㅖㅗㅘㅙㅚㅛㅜㅝㅞㅟㅠㅡㅢㅣ") +JONGSUNG = [""] + list("ㄱㄲㄳㄴㄵㄶㄷㄹㄺㄻㄼㄽㄾㄿㅀㅁㅂㅄㅅㅆㅇㅈㅊㅋㅌㅍㅎ") + +HANGUL_BASE = 0xAC00 +HANGUL_LAST = 0xD7A3 + + +def decompose_hangul(char): + """완성형 한글 한 글자를 (초성, 중성, 종성) 튜플로 분해한다. 한글이 아니면 None.""" + code = ord(char) + if not (HANGUL_BASE <= code <= HANGUL_LAST): + return None + offset = code - HANGUL_BASE + cho = offset // (21 * 28) + jung = (offset % (21 * 28)) // 28 + jong = offset % 28 + return (CHOSUNG[cho], JUNGSUNG[jung], JONGSUNG[jong]) + + +def tokenize(text): + """문자열을 자모 단위(한글은 초/중/종성으로 분해, 그 외는 원래 글자 그대로) 토큰 리스트로 변환한다.""" + tokens = [] + for char in text: + decomposed = decompose_hangul(char) + if decomposed: + cho, jung, jong = decomposed + tokens.append(cho) + tokens.append(jung) + if jong: + tokens.append("_" + jong) # 종성은 초성과 겹치지 않도록 접두어를 붙인다 + else: + tokens.append(char) + return tokens + + +def build_vocabulary(memos): + """시드 데이터에 등장하는 모든 자모/문자를 모아 정렬된 vocabulary를 만든다.""" + tokens = set() + for memo in memos: + tokens.update(tokenize(memo)) + return sorted(tokens) + + +def vectorize(memo, vocabulary): + """memo 문자열을 vocabulary 기준 자모 빈도 벡터로 변환한다.""" + counts = Counter(tokenize(memo)) + return [float(counts.get(token, 0)) for token in vocabulary] + + +def main(): + with open(SEED_DATA_PATH, encoding="utf-8") as f: + seed_data = json.load(f) + + memos = [item["memo"] for item in seed_data] + labels = [item["category"] for item in seed_data] + + vocabulary = build_vocabulary(memos) + print(f"Vocabulary 크기: {len(vocabulary)}개 (자모/문자 단위)") + + vectors = [vectorize(memo, vocabulary) for memo in memos] + + builder = KNearestNeighborsClassifierBuilder( + input_name="input", + output_name="label", + number_of_dimensions=len(vocabulary), + default_class_label=DEFAULT_CATEGORY, + number_of_neighbors=3, + weighting_scheme="inverse_distance", + ) + builder.author = "SpendLearning" + builder.license = "Personal" + builder.description = "음성으로 입력한 상호명을 지출 카테고리로 분류하는 kNN 모델" + + builder.add_samples(vectors, labels) + + model = MLModel(builder.spec) + model.save(MODEL_OUTPUT_PATH) + print(f"모델 저장 완료: {MODEL_OUTPUT_PATH}") + + with open(VOCAB_OUTPUT_PATH, "w", encoding="utf-8") as f: + json.dump(vocabulary, f, ensure_ascii=False, indent=2) + print(f"Vocabulary 저장 완료: {VOCAB_OUTPUT_PATH}") + + +if __name__ == "__main__": + main() diff --git a/SpendLearning/SpendLearning.xcodeproj/project.pbxproj b/SpendLearning/SpendLearning.xcodeproj/project.pbxproj index 4443baa..58e30db 100644 --- a/SpendLearning/SpendLearning.xcodeproj/project.pbxproj +++ b/SpendLearning/SpendLearning.xcodeproj/project.pbxproj @@ -32,6 +32,7 @@ BCB415242FF52E7D00C9CABD /* Exceptions for "SpendLearning" folder in "SpendLearningTests" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( + Source/Data/Predictors/CategoryPredictor.swift, Source/Domain/Entities/Expense/CalendarDay.swift, Source/Domain/Entities/Expense/Category.swift, Source/Domain/Entities/Expense/Expense.swift, diff --git a/SpendLearning/SpendLearning/Resource/CategoryClassifier.mlmodel b/SpendLearning/SpendLearning/Resource/CategoryClassifier.mlmodel new file mode 100644 index 0000000..84558c7 Binary files /dev/null and b/SpendLearning/SpendLearning/Resource/CategoryClassifier.mlmodel differ diff --git a/SpendLearning/SpendLearning/Resource/Info.plist b/SpendLearning/SpendLearning/Resource/Info.plist index 0eb786d..1df64cf 100644 --- a/SpendLearning/SpendLearning/Resource/Info.plist +++ b/SpendLearning/SpendLearning/Resource/Info.plist @@ -2,6 +2,10 @@ + NSMicrophoneUsageDescription + 음성으로 소비를 기록하기 위해 마이크 접근 권한이 필요합니다. + NSSpeechRecognitionUsageDescription + 음성을 텍스트로 변환해 자동으로 입력하기 위해 음성 인식 권한이 필요합니다. UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/SpendLearning/SpendLearning/Resource/vocabulary.json b/SpendLearning/SpendLearning/Resource/vocabulary.json new file mode 100644 index 0000000..92a23a3 --- /dev/null +++ b/SpendLearning/SpendLearning/Resource/vocabulary.json @@ -0,0 +1,92 @@ +[ + " ", + "&", + "+", + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "A", + "C", + "G", + "H", + "I", + "K", + "L", + "M", + "N", + "O", + "P", + "R", + "S", + "T", + "U", + "V", + "X", + "_ㄱ", + "_ㄲ", + "_ㄴ", + "_ㄹ", + "_ㅁ", + "_ㅂ", + "_ㅅ", + "_ㅇ", + "_ㅊ", + "_ㅌ", + "a", + "b", + "c", + "d", + "e", + "g", + "h", + "i", + "k", + "n", + "r", + "s", + "t", + "u", + "ㄱ", + "ㄲ", + "ㄴ", + "ㄷ", + "ㄸ", + "ㄹ", + "ㅁ", + "ㅂ", + "ㅃ", + "ㅅ", + "ㅆ", + "ㅇ", + "ㅈ", + "ㅉ", + "ㅊ", + "ㅋ", + "ㅌ", + "ㅍ", + "ㅎ", + "ㅏ", + "ㅐ", + "ㅑ", + "ㅓ", + "ㅔ", + "ㅕ", + "ㅖ", + "ㅗ", + "ㅘ", + "ㅙ", + "ㅚ", + "ㅛ", + "ㅜ", + "ㅝ", + "ㅞ", + "ㅟ", + "ㅠ", + "ㅡ", + "ㅢ", + "ㅣ" +] diff --git a/SpendLearning/SpendLearning/Source/App/SceneDelegate.swift b/SpendLearning/SpendLearning/Source/App/SceneDelegate.swift index 4b90f73..8bcac0f 100644 --- a/SpendLearning/SpendLearning/Source/App/SceneDelegate.swift +++ b/SpendLearning/SpendLearning/Source/App/SceneDelegate.swift @@ -52,6 +52,23 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { selectedImage: UIImage(systemName: "house.fill") ) + let categoryPredictor = CategoryPredictor() + let voiceExpenseViewModel = VoiceExpenseViewModel( + categoryUseCase: categoryUseCase, + categoryPredictor: categoryPredictor + ) + let voiceExpenseViewController = VoiceExpenseViewController( + viewModel: voiceExpenseViewModel, + expenseUseCase: expenseUseCase, + categoryUseCase: categoryUseCase, + categoryPredictor: categoryPredictor + ) + voiceExpenseViewController.tabBarItem = UITabBarItem( + title: "음성추가", + image: UIImage(systemName: "microphone"), + selectedImage: UIImage(systemName: "microphone.fill") + ) + 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)) @@ -64,7 +81,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { let settingsViewController = SettingsViewController( categoryUseCase: categoryUseCase, expenseUseCase: expenseUseCase, - predictionUseCase: predictionUseCase + predictionUseCase: predictionUseCase, + categoryPredictor: categoryPredictor ) settingsViewController.tabBarItem = UITabBarItem( title: "설정", @@ -73,7 +91,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { ) let tabBar = UITabBarController() - tabBar.viewControllers = [homeViewController, predictionViewController, settingsViewController] + tabBar.viewControllers = [homeViewController, voiceExpenseViewController, predictionViewController, settingsViewController] tabBar.tabBar.tintColor = .DesignSystem.accent return tabBar diff --git a/SpendLearning/SpendLearning/Source/Data/Predictors/CategoryPredictor.swift b/SpendLearning/SpendLearning/Source/Data/Predictors/CategoryPredictor.swift new file mode 100644 index 0000000..f4a5ec7 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Data/Predictors/CategoryPredictor.swift @@ -0,0 +1,173 @@ +// +// CategoryPredictor.swift +// SpendLearning +// +// Created by 김성훈 on 7/16/26. +// + +import CoreML +import Foundation + +final class CategoryPredictor { + + // MARK: - Constants + + private static let chosung = Array("ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ").map(String.init) + private static let jungsung = Array("ㅏㅐㅑㅒㅓㅔㅕㅖㅗㅘㅙㅚㅛㅜㅝㅞㅟㅠㅡㅢㅣ").map(String.init) + private static let jongsung = [""] + Array("ㄱㄲㄳㄴㄵㄶㄷㄹㄺㄻㄼㄽㄾㄿㅀㅁㅂㅄㅅㅆㅇㅈㅊㅋㅌㅍㅎ").map(String.init) + + private static let fallbackCategory = "기타" + + // MARK: - Private + + private let vocabulary: [String] + private var modelURL: URL? + private var model: MLModel? + + // MARK: - Init + + init() { + self.vocabulary = Self.loadVocabulary() + prepareWritableModel() + } + + // MARK: - Predict + + /// memo로부터 카테고리를 예측한다. 실패해도 항상 폴백 카테고리를 반환한다. + func predict(memo: String) -> (category: String, confidence: Double) { + guard let model, let vector = try? Self.vectorize(memo, vocabulary: vocabulary) else { + return (Self.fallbackCategory, 0) + } + do { + let provider = try MLDictionaryFeatureProvider(dictionary: ["input": vector]) + let output = try model.prediction(from: provider) + guard let label = output.featureValue(for: "label")?.stringValue else { + return (Self.fallbackCategory, 0) + } + let confidence = output.featureValue(for: "labelProbs")?.dictionaryValue[label]?.doubleValue ?? 0 + return (label, confidence) + } catch { + return (Self.fallbackCategory, 0) + } + } + + // MARK: - Update + + /// 사용자가 확정한 (memo, category) 쌍을 모델에 즉시 반영해 온디바이스로 학습한다. + func update(memo: String, confirmedCategory: String) async throws { + guard let modelURL else { return } + let vector = try Self.vectorize(memo, vocabulary: vocabulary) + let row = try MLDictionaryFeatureProvider(dictionary: ["input": vector, "label": confirmedCategory]) + let trainingData = MLArrayBatchProvider(array: [row]) + + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + do { + let task = try MLUpdateTask( + forModelAt: modelURL, + trainingData: trainingData, + configuration: nil, + completionHandler: { [weak self] context in + do { + try context.model.write(to: modelURL) + self?.model = try MLModel(contentsOf: modelURL) + continuation.resume() + } catch { + continuation.resume(throwing: error) + } + } + ) + task.resume() + } catch { + continuation.resume(throwing: error) + } + } + } + + // MARK: - Reset + + /// 온디바이스 학습 데이터를 모두 지우고 번들에 포함된 원본 모델로 되돌린다. + func resetToBundledModel() { + guard let modelURL else { return } + let fileManager = FileManager.default + try? fileManager.removeItem(at: modelURL) + + guard let bundledCompiledURL = Bundle.main.url(forResource: "CategoryClassifier", withExtension: "mlmodelc") else { + return + } + try? fileManager.copyItem(at: bundledCompiledURL, to: modelURL) + self.model = try? MLModel(contentsOf: modelURL) + } +} + +// MARK: - Setup + +private extension CategoryPredictor { + + func prepareWritableModel() { + let fileManager = FileManager.default + guard let supportDir = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { + return + } + try? fileManager.createDirectory(at: supportDir, withIntermediateDirectories: true) + + let writableURL = supportDir.appendingPathComponent("CategoryClassifier.mlmodelc") + self.modelURL = writableURL + + guard let bundledCompiledURL = Bundle.main.url(forResource: "CategoryClassifier", withExtension: "mlmodelc") else { + self.model = try? MLModel(contentsOf: writableURL) + return + } + + try? fileManager.removeItem(at: writableURL) + try? fileManager.copyItem(at: bundledCompiledURL, to: writableURL) + + self.model = try? MLModel(contentsOf: writableURL) + } + + static func loadVocabulary() -> [String] { + guard let url = Bundle.main.url(forResource: "vocabulary", withExtension: "json"), + let data = try? Data(contentsOf: url), + let vocabulary = try? JSONDecoder().decode([String].self, from: data) else { + return [] + } + return vocabulary + } +} + +// MARK: - Vectorization (train.py의 tokenize/vectorize와 반드시 동일해야 한다) + +private extension CategoryPredictor { + + static func tokenize(_ text: String) -> [String] { + var tokens: [String] = [] + for scalar in text.unicodeScalars { + let code = Int(scalar.value) + if code >= 0xAC00 && code <= 0xD7A3 { + let offset = code - 0xAC00 + let choIndex = offset / (21 * 28) + let jungIndex = (offset % (21 * 28)) / 28 + let jongIndex = offset % 28 + tokens.append(chosung[choIndex]) + tokens.append(jungsung[jungIndex]) + if jongIndex != 0 { + tokens.append("_" + jongsung[jongIndex]) + } + } else { + tokens.append(String(scalar)) + } + } + return tokens + } + + static func vectorize(_ text: String, vocabulary: [String]) throws -> MLMultiArray { + var counts: [String: Int] = [:] + for token in tokenize(text) { + counts[token, default: 0] += 1 + } + let array = try MLMultiArray(shape: [NSNumber(value: vocabulary.count)], dataType: .double) + for (index, token) in vocabulary.enumerated() { + array[index] = NSNumber(value: Double(counts[token] ?? 0)) + } + return array + } +} diff --git a/SpendLearning/SpendLearning/Source/Domain/Parsers/VoiceExpenseParser.swift b/SpendLearning/SpendLearning/Source/Domain/Parsers/VoiceExpenseParser.swift new file mode 100644 index 0000000..b708576 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Domain/Parsers/VoiceExpenseParser.swift @@ -0,0 +1,48 @@ +// +// VoiceExpenseParser.swift +// SpendLearning +// +// Created by 김성훈 on 7/16/26. +// + +import Foundation + +struct VoiceExpenseParser { + + struct ParsedExpense { + let memo: String + let amount: Int + } + + enum ParseError: Error { + case amountNotFound + case memoEmpty + } + + /// "스타벅스 4500원" 같은 문장에서 메모와 금액을 분리한다. + func parse(_ transcript: String) -> Result { + let trimmed = transcript.trimmingCharacters(in: .whitespacesAndNewlines) + + guard let range = trimmed.range( + of: #"[0-9][0-9,]*\s*(원|₩)"#, + options: .regularExpression + ) else { + return .failure(.amountNotFound) + } + + let amountDigits = trimmed[range].filter { $0.isNumber } + guard let amount = Int(amountDigits), amount > 0 else { + return .failure(.amountNotFound) + } + + let memo = trimmed[trimmed.startIndex..() + var onCategorySelected: (() -> Void)? + // MARK: - Init init(viewModel: NewExpenseViewModel) { self.viewModel = viewModel @@ -60,9 +62,7 @@ extension CategorySelectViewController: UITableViewDataSource, UITableViewDelega func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { let category = viewModel.categories[indexPath.row] viewModel.didSelectCategory(category) - let inputVC = ExpenseInputViewController(viewModel: viewModel) - inputVC.modalPresentationStyle = .fullScreen - present(inputVC, animated: true) + onCategorySelected?() } } diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/NewExpense/ExpenseInputViewController.swift b/SpendLearning/SpendLearning/Source/Presentation/View/NewExpense/ExpenseInputViewController.swift index 7bb80e1..9da033b 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/NewExpense/ExpenseInputViewController.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/NewExpense/ExpenseInputViewController.swift @@ -11,22 +11,33 @@ import Combine final class ExpenseInputViewController: UIViewController { // MARK: - UI - private lazy var navigationBar = CustomNavigationBar( - title: viewModel.selectedCategory?.displayName ?? "", + private let navigationBar = CustomNavigationBar( + title: "지출 확인", leftButtonTitle: "뒤로", rightButtonTitle: "저장" ) - private let amountLabel = UILabel() + private let cardView = UIView() + + private let categoryRow = UIView() + private let categoryEmojiLabel = UILabel() + private let categoryNameLabel = UILabel() + private let categoryChevronImageView = UIImageView() + private let categorySeparator = UIView() + + private let amountFieldLabel = UILabel() + private let amountTextField = UITextField() + private let amountSeparator = UIView() + + private let memoFieldLabel = UILabel() private let memoTextField = UITextField() - private let amountTextField: UITextField = { - let tf = UITextField() - tf.keyboardType = .numberPad - tf.isHidden = true - return tf - }() // MARK: - Properties private let viewModel: NewExpenseViewModel + private var cancellables = Set() + + /// 저장이 성공적으로 끝난 직후 호출 + /// 음성추가 흐름에서 CategoryPredictor.update를 트리거하는 용도로 사용 + var onDidSave: (() -> Void)? // MARK: - Init init(viewModel: NewExpenseViewModel) { @@ -43,6 +54,7 @@ final class ExpenseInputViewController: UIViewController { super.viewDidLoad() view.backgroundColor = .DesignSystem.background setup() + bindSelectedCategory() } override func viewWillAppear(_ animated: Bool) { @@ -57,7 +69,8 @@ private extension ExpenseInputViewController { @objc func didTapSave() { Task { await viewModel.didSaveExpense() - presentingViewController?.presentingViewController?.dismiss(animated: true) + onDidSave?() + dismiss(animated: true) } } @@ -65,14 +78,21 @@ private extension ExpenseInputViewController { dismiss(animated: true) } - @objc func didTapAmountLabel() { - amountTextField.becomeFirstResponder() + @objc func didTapCategoryRow() { + let categorySelectVC = CategorySelectViewController(viewModel: viewModel) + categorySelectVC.modalPresentationStyle = .fullScreen + categorySelectVC.onCategorySelected = { [weak self, weak categorySelectVC] in + categorySelectVC?.dismiss(animated: true) + self?.updateCategoryDisplay() + } + present(categorySelectVC, animated: true) } @objc func amountDidChange() { - let amount = Int(amountTextField.text ?? "") ?? 0 + let digitsOnly = (amountTextField.text ?? "").filter { $0.isNumber } + let amount = Int(digitsOnly) ?? 0 viewModel.didInputAmount(amount) - amountLabel.text = amount == 0 ? "0원" : "\(amount.formatted())원" + amountTextField.text = amount == 0 ? "" : "\(amount.formatted())원" } } @@ -90,8 +110,12 @@ private extension ExpenseInputViewController { func setup() { setupNavigationBar() - setupSubviews() + setupCardView() + setupCategoryRow() + setupAmountField() + setupMemoField() setupConstraints() + updateCategoryDisplay() } func setupNavigationBar() { @@ -103,37 +127,76 @@ private extension ExpenseInputViewController { } } - func setupSubviews() { - let initialAmount = viewModel.initialAmount - amountLabel.text = initialAmount == 0 ? "0원" : "\(initialAmount.formatted())원" - amountLabel.font = .systemFont(ofSize: 40, weight: .bold) - amountLabel.textColor = .DesignSystem.primary - amountLabel.textAlignment = .center - amountLabel.isUserInteractionEnabled = true - amountLabel.adjustsFontSizeToFitWidth = true - amountLabel.minimumScaleFactor = 0.5 - amountLabel.addGestureRecognizer( - UITapGestureRecognizer(target: self, action: #selector(didTapAmountLabel)) - ) + func setupCardView() { + cardView.backgroundColor = .DesignSystem.surface + cardView.layer.cornerRadius = 16 + + [navigationBar, cardView].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + view.addSubview($0) + } + } + + func setupCategoryRow() { + categoryEmojiLabel.font = .systemFont(ofSize: 20) + + categoryNameLabel.font = .systemFont(ofSize: 16, weight: .semibold) + categoryNameLabel.textColor = .DesignSystem.primary + + categoryChevronImageView.image = UIImage(systemName: "chevron.right") + categoryChevronImageView.tintColor = .DesignSystem.subtitle + categoryChevronImageView.contentMode = .scaleAspectFit + + categoryRow.isUserInteractionEnabled = true + categoryRow.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTapCategoryRow))) + + categorySeparator.backgroundColor = .DesignSystem.separator + + [categoryEmojiLabel, categoryNameLabel, categoryChevronImageView].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + categoryRow.addSubview($0) + } + [categoryRow, categorySeparator].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + cardView.addSubview($0) + } + } + + func setupAmountField() { + amountFieldLabel.text = "금액" + amountFieldLabel.font = .systemFont(ofSize: 13, weight: .regular) + amountFieldLabel.textColor = .DesignSystem.subtitle + + amountTextField.text = viewModel.initialAmount == 0 ? "" : "\(viewModel.initialAmount.formatted())원" + amountTextField.placeholder = "0" + amountTextField.font = .systemFont(ofSize: 20, weight: .semibold) + amountTextField.textColor = .DesignSystem.primary + amountTextField.keyboardType = .numberPad + amountTextField.addTarget(self, action: #selector(amountDidChange), for: .editingChanged) + + amountSeparator.backgroundColor = .DesignSystem.separator + + [amountFieldLabel, amountTextField, amountSeparator].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + cardView.addSubview($0) + } + } + + func setupMemoField() { + memoFieldLabel.text = "메모" + memoFieldLabel.font = .systemFont(ofSize: 13, weight: .regular) + memoFieldLabel.textColor = .DesignSystem.subtitle - memoTextField.placeholder = "메모를 남겨보세요" memoTextField.text = viewModel.initialMemo - memoTextField.font = .systemFont(ofSize: 15) - memoTextField.backgroundColor = .white - memoTextField.layer.cornerRadius = 12 - memoTextField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 16, height: 0)) - memoTextField.leftViewMode = .always - memoTextField.keyboardType = .default + memoTextField.placeholder = "메모를 남겨보세요" + memoTextField.font = .systemFont(ofSize: 16) memoTextField.autocapitalizationType = .none memoTextField.autocorrectionType = .no memoTextField.delegate = self - amountTextField.text = initialAmount == 0 ? "" : "\(initialAmount)" - amountTextField.addTarget(self, action: #selector(amountDidChange), for: .editingChanged) - - [navigationBar, amountLabel, memoTextField, amountTextField].forEach { + [memoFieldLabel, memoTextField].forEach { $0.translatesAutoresizingMaskIntoConstraints = false - view.addSubview($0) + cardView.addSubview($0) } } @@ -143,14 +206,65 @@ private extension ExpenseInputViewController { navigationBar.leadingAnchor.constraint(equalTo: view.leadingAnchor), navigationBar.trailingAnchor.constraint(equalTo: view.trailingAnchor), - amountLabel.topAnchor.constraint(equalTo: navigationBar.bottomAnchor, constant: 70), - amountLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24), - amountLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24), + cardView.topAnchor.constraint(equalTo: navigationBar.bottomAnchor, constant: 24), + cardView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + cardView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), + + categoryRow.topAnchor.constraint(equalTo: cardView.topAnchor, constant: 16), + categoryRow.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 16), + categoryRow.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -16), + categoryRow.heightAnchor.constraint(equalToConstant: 28), - memoTextField.topAnchor.constraint(equalTo: amountLabel.bottomAnchor, constant: 60), - memoTextField.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), - memoTextField.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), - memoTextField.heightAnchor.constraint(equalToConstant: 48), + categoryEmojiLabel.leadingAnchor.constraint(equalTo: categoryRow.leadingAnchor), + categoryEmojiLabel.centerYAnchor.constraint(equalTo: categoryRow.centerYAnchor), + + categoryNameLabel.leadingAnchor.constraint(equalTo: categoryEmojiLabel.trailingAnchor, constant: 8), + categoryNameLabel.centerYAnchor.constraint(equalTo: categoryRow.centerYAnchor), + + categoryChevronImageView.trailingAnchor.constraint(equalTo: categoryRow.trailingAnchor), + categoryChevronImageView.centerYAnchor.constraint(equalTo: categoryRow.centerYAnchor), + categoryChevronImageView.widthAnchor.constraint(equalToConstant: 14), + categoryChevronImageView.heightAnchor.constraint(equalToConstant: 20), + + categorySeparator.topAnchor.constraint(equalTo: categoryRow.bottomAnchor, constant: 16), + categorySeparator.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 16), + categorySeparator.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -16), + categorySeparator.heightAnchor.constraint(equalToConstant: 1), + + amountFieldLabel.topAnchor.constraint(equalTo: categorySeparator.bottomAnchor, constant: 16), + amountFieldLabel.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 16), + + amountTextField.topAnchor.constraint(equalTo: amountFieldLabel.bottomAnchor, constant: 4), + amountTextField.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 16), + amountTextField.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -16), + + amountSeparator.topAnchor.constraint(equalTo: amountTextField.bottomAnchor, constant: 16), + amountSeparator.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 16), + amountSeparator.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -16), + amountSeparator.heightAnchor.constraint(equalToConstant: 1), + + memoFieldLabel.topAnchor.constraint(equalTo: amountSeparator.bottomAnchor, constant: 16), + memoFieldLabel.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 16), + + memoTextField.topAnchor.constraint(equalTo: memoFieldLabel.bottomAnchor, constant: 4), + memoTextField.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 16), + memoTextField.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -16), + memoTextField.bottomAnchor.constraint(equalTo: cardView.bottomAnchor, constant: -16), ]) } + + func bindSelectedCategory() { + viewModel.$selectedCategory + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.updateCategoryDisplay() + } + .store(in: &cancellables) + } + + func updateCategoryDisplay() { + let category = viewModel.selectedCategory + categoryEmojiLabel.text = category?.emoji ?? "📦" + categoryNameLabel.text = category?.displayName ?? "기타" + } } diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/NewExpense/NewExpenseViewModel.swift b/SpendLearning/SpendLearning/Source/Presentation/View/NewExpense/NewExpenseViewModel.swift index 956bce9..df378c3 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/NewExpense/NewExpenseViewModel.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/NewExpense/NewExpenseViewModel.swift @@ -16,8 +16,9 @@ final class NewExpenseViewModel { @Published private(set) var categories: [Category] = [] @Published private(set) var fetchError: Error? = nil - var initialAmount: Int { editingExpense?.amount ?? 0 } - var initialMemo: String { editingExpense?.memo ?? "" } + var initialAmount: Int { amount } + var initialMemo: String { memo } + var finalMemo: String { memo } // MARK: - Private private let expenseUseCase: ExpenseUseCaseProtocol @@ -32,7 +33,10 @@ final class NewExpenseViewModel { expenseUseCase: ExpenseUseCaseProtocol, categoryUseCase: CategoryUseCaseProtocol, date: Date, - editingExpense: Expense? = nil + editingExpense: Expense? = nil, + prefillAmount: Int? = nil, + prefillMemo: String? = nil, + prefillCategory: Category? = nil ) { self.expenseUseCase = expenseUseCase self.categoryUseCase = categoryUseCase @@ -43,6 +47,10 @@ final class NewExpenseViewModel { self.amount = expense.amount self.memo = expense.memo ?? "" self.selectedCategory = expense.category + } else { + self.amount = prefillAmount ?? 0 + self.memo = prefillMemo ?? "" + self.selectedCategory = prefillCategory } } diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/Prediction/PredictionStatusCardView.swift b/SpendLearning/SpendLearning/Source/Presentation/View/Prediction/PredictionStatusCardView.swift index 10a7709..485b2a9 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/Prediction/PredictionStatusCardView.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/Prediction/PredictionStatusCardView.swift @@ -53,6 +53,10 @@ struct PredictionStatusCardView: View { .font(.system(size: 13, weight: .semibold)) .foregroundStyle(Color(UIColor.DesignSystem.subtitle)) + Text("지난달들의 기록이 쌓일수록 정확해져요") + .font(.system(size: 12)) + .foregroundStyle(Color(UIColor.DesignSystem.subtitle).opacity(0.8)) + if let accuracy { ProgressView(value: accuracy / 100) .tint(Color(UIColor.DesignSystem.accent)) diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/Prediction/PredictionViewModel.swift b/SpendLearning/SpendLearning/Source/Presentation/View/Prediction/PredictionViewModel.swift index 4e52041..3c5d4fa 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/Prediction/PredictionViewModel.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/Prediction/PredictionViewModel.swift @@ -39,7 +39,7 @@ final class PredictionViewModel { actual > 0 else { return nil } let errorRatio = abs(Double(predicted) - Double(actual)) / Double(actual) - return Float(100 * exp(-errorRatio)) + return Float(100 * exp(-2 * errorRatio)) } // MARK: - Init @@ -80,6 +80,7 @@ final class PredictionViewModel { } private func makeCumulativePrediction(expenses: [Expense], predictions: [Int: Int]) -> [CumulativePrediction] { + let hasAnyExpense = !expenses.isEmpty var cumulativeActual = 0 var cumulativePredicted = 0 @@ -97,7 +98,7 @@ final class PredictionViewModel { return CumulativePrediction( day: day, - actual: day <= today ? cumulativeActual : nil, + actual: (day <= today && hasAnyExpense) ? cumulativeActual : nil, predicted: predictions[day] != nil ? cumulativePredicted : nil ) } diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/Settings/SettingsViewController.swift b/SpendLearning/SpendLearning/Source/Presentation/View/Settings/SettingsViewController.swift index 50be54b..05ef1df 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/Settings/SettingsViewController.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/Settings/SettingsViewController.swift @@ -25,12 +25,14 @@ final class SettingsViewController: UIViewController { init( categoryUseCase: CategoryUseCaseProtocol, expenseUseCase: ExpenseUseCaseProtocol, - predictionUseCase: PredictionUseCaseProtocol + predictionUseCase: PredictionUseCaseProtocol, + categoryPredictor: CategoryPredictor ) { self.viewModel = SettingsViewModel( categoryUseCase: categoryUseCase, expenseUseCase: expenseUseCase, - predictionUseCase: predictionUseCase + predictionUseCase: predictionUseCase, + categoryPredictor: categoryPredictor ) super.init(nibName: nil, bundle: nil) } diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/Settings/SettingsViewModel.swift b/SpendLearning/SpendLearning/Source/Presentation/View/Settings/SettingsViewModel.swift index 391325e..8c412f2 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/Settings/SettingsViewModel.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/Settings/SettingsViewModel.swift @@ -20,16 +20,18 @@ final class SettingsViewModel { private let categoryUseCase: CategoryUseCaseProtocol private let expenseUseCase: ExpenseUseCaseProtocol private let predictionUseCase: PredictionUseCaseProtocol + private let categoryPredictor: CategoryPredictor - // MARK: - Init init( categoryUseCase: CategoryUseCaseProtocol, expenseUseCase: ExpenseUseCaseProtocol, - predictionUseCase: PredictionUseCaseProtocol + predictionUseCase: PredictionUseCaseProtocol, + categoryPredictor: CategoryPredictor ) { self.categoryUseCase = categoryUseCase self.expenseUseCase = expenseUseCase self.predictionUseCase = predictionUseCase + self.categoryPredictor = categoryPredictor } // MARK: - Input @@ -96,6 +98,7 @@ final class SettingsViewModel { func resetAllData() async { await expenseUseCase.deleteAll() await predictionUseCase.deleteModel() + categoryPredictor.resetToBundledModel() do { try await categoryUseCase.resetToDefault() categories = (try? await categoryUseCase.fetchCategories()) ?? categories diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/VoiceExpense/VoiceExpenseViewController.swift b/SpendLearning/SpendLearning/Source/Presentation/View/VoiceExpense/VoiceExpenseViewController.swift new file mode 100644 index 0000000..8805701 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Presentation/View/VoiceExpense/VoiceExpenseViewController.swift @@ -0,0 +1,289 @@ +// +// VoiceExpenseViewController.swift +// SpendLearning +// +// Created by 김성훈 on 7/16/26. +// + +import UIKit + +final class VoiceExpenseViewController: UIViewController { + + // MARK: - UI + private let titleLabel = UILabel() + + private let transcriptCardView = UIView() + private let fixedGuideLabel = UILabel() + private let transcriptSeparator = UIView() + private let contentLabel = UILabel() + private let settingsButton = UIButton(type: .system) + + private let micCardView = UIView() + private let micButton = UIButton() + private let hintLabel = UILabel() + + // MARK: - Properties + private let viewModel: VoiceExpenseViewModel + private let expenseUseCase: ExpenseUseCaseProtocol + private let categoryUseCase: CategoryUseCaseProtocol + private let categoryPredictor: CategoryPredictor + private var displayLink: CADisplayLink? + + // MARK: - Init + init( + viewModel: VoiceExpenseViewModel, + expenseUseCase: ExpenseUseCaseProtocol, + categoryUseCase: CategoryUseCaseProtocol, + categoryPredictor: CategoryPredictor + ) { + self.viewModel = viewModel + self.expenseUseCase = expenseUseCase + self.categoryUseCase = categoryUseCase + self.categoryPredictor = categoryPredictor + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Lifecycle + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .DesignSystem.background + setup() + render() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + startObservingState() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + stopObservingState() + } +} + +// MARK: - State Observation +// @Observable은 SwiftUI 밖(UIKit)에서는 자동 구독이 없어, 짧은 주기로 폴링해 상태 변화를 감지 +// 녹음 중 실시간 자막 갱신이 필요해 매 프레임 체크가 필요하므로 CADisplayLink를 사용 +private extension VoiceExpenseViewController { + + func startObservingState() { + displayLink?.invalidate() + let link = CADisplayLink(target: self, selector: #selector(pollState)) + link.add(to: .main, forMode: .common) + displayLink = link + } + + func stopObservingState() { + displayLink?.invalidate() + displayLink = nil + } + + @objc func pollState() { + render() + } +} + +// MARK: - Actions +private extension VoiceExpenseViewController { + + @objc func didTouchDown() { + Task { await viewModel.startRecording() } + } + + @objc func didTouchUp() { + Task { await viewModel.stopRecording() } + } + + @objc func didTapSettings() { + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + UIApplication.shared.open(url) + } +} + +// MARK: - Render +private extension VoiceExpenseViewController { + + func render() { + settingsButton.isHidden = true + + switch viewModel.state { + case .idle: + contentLabel.text = VoiceExpenseViewModel.detailGuideText + contentLabel.font = .systemFont(ofSize: 14, weight: .regular) + contentLabel.textColor = .DesignSystem.subtitle + micButton.transform = .identity + + case .recording(let transcript): + contentLabel.text = transcript.isEmpty ? "듣고 있어요..." : transcript + contentLabel.font = .systemFont(ofSize: 20, weight: .medium) + contentLabel.textColor = .DesignSystem.primary + micButton.transform = CGAffineTransform(scaleX: 1.15, y: 1.15) + + case .parsed(let memo, let amount, let category): + micButton.transform = .identity + presentConfirm(memo: memo, amount: amount, category: category) + + case .error(let message): + contentLabel.text = message + contentLabel.font = .systemFont(ofSize: 16, weight: .medium) + contentLabel.textColor = .DesignSystem.primary + micButton.transform = .identity + + case .permissionDenied(let message): + contentLabel.text = message + contentLabel.font = .systemFont(ofSize: 16, weight: .medium) + contentLabel.textColor = .systemRed + micButton.transform = .identity + settingsButton.isHidden = false + } + } + + func presentConfirm(memo: String, amount: Int, category: Category) { + viewModel.reset() + let vm = NewExpenseViewModel( + expenseUseCase: expenseUseCase, + categoryUseCase: categoryUseCase, + date: Date(), + prefillAmount: amount, + prefillMemo: memo, + prefillCategory: category + ) + Task { + await vm.loadCategories() + let inputVC = ExpenseInputViewController(viewModel: vm) + inputVC.modalPresentationStyle = .fullScreen + inputVC.onDidSave = { [weak self] in + guard let self else { return } + Task { + try? await self.categoryPredictor.update( + memo: vm.finalMemo, + confirmedCategory: vm.selectedCategory?.name ?? category.name + ) + } + } + present(inputVC, animated: true) + } + } +} + +// MARK: - Helper +private extension VoiceExpenseViewController { + + func setup() { + setupSubviews() + setupConstraints() + } + + func setupSubviews() { + titleLabel.text = "음성으로 추가" + titleLabel.font = .systemFont(ofSize: 24, weight: .bold) + titleLabel.textColor = .DesignSystem.primary + + transcriptCardView.backgroundColor = .DesignSystem.surface + transcriptCardView.layer.cornerRadius = 16 + + fixedGuideLabel.text = "\"메모 금액\" 순서로 말해주세요" + fixedGuideLabel.font = .systemFont(ofSize: 15, weight: .semibold) + fixedGuideLabel.textColor = .DesignSystem.primary + fixedGuideLabel.textAlignment = .center + + transcriptSeparator.backgroundColor = .DesignSystem.separator + + contentLabel.text = VoiceExpenseViewModel.detailGuideText + contentLabel.font = .systemFont(ofSize: 14, weight: .regular) + contentLabel.textColor = .DesignSystem.subtitle + contentLabel.textAlignment = .center + contentLabel.numberOfLines = 0 + + settingsButton.setTitle("설정으로 이동", for: .normal) + settingsButton.setTitleColor(.DesignSystem.accent, for: .normal) + settingsButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .semibold) + settingsButton.addTarget(self, action: #selector(didTapSettings), for: .touchUpInside) + settingsButton.isHidden = true + + micCardView.backgroundColor = .DesignSystem.surface + micCardView.layer.cornerRadius = 16 + + let micSymbolConfig = UIImage.SymbolConfiguration(pointSize: 72, weight: .regular) + micButton.setImage(UIImage(systemName: "mic.circle.fill", withConfiguration: micSymbolConfig), for: .normal) + micButton.tintColor = .DesignSystem.accent + micButton.imageView?.contentMode = .scaleAspectFit + micButton.addTarget(self, action: #selector(didTouchDown), for: .touchDown) + micButton.addTarget(self, action: #selector(didTouchUp), for: [.touchUpInside, .touchUpOutside, .touchCancel]) + + hintLabel.text = "눌러서 말하기" + hintLabel.font = .systemFont(ofSize: 13, weight: .regular) + hintLabel.textColor = .DesignSystem.subtitle + hintLabel.textAlignment = .center + + [fixedGuideLabel, transcriptSeparator, contentLabel, settingsButton].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + transcriptCardView.addSubview($0) + } + [micButton, hintLabel].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + micCardView.addSubview($0) + } + [titleLabel, transcriptCardView, micCardView].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + view.addSubview($0) + } + } + + func setupConstraints() { + NSLayoutConstraint.activate([ + titleLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16), + titleLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + + transcriptCardView.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 24), + transcriptCardView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + transcriptCardView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), + transcriptCardView.bottomAnchor.constraint(equalTo: micCardView.topAnchor, constant: -24), + + fixedGuideLabel.topAnchor.constraint(equalTo: transcriptCardView.topAnchor, constant: 24), + fixedGuideLabel.leadingAnchor.constraint(equalTo: transcriptCardView.leadingAnchor, constant: 20), + fixedGuideLabel.trailingAnchor.constraint(equalTo: transcriptCardView.trailingAnchor, constant: -20), + + transcriptSeparator.topAnchor.constraint(equalTo: fixedGuideLabel.bottomAnchor, constant: 16), + transcriptSeparator.leadingAnchor.constraint(equalTo: transcriptCardView.leadingAnchor, constant: 20), + transcriptSeparator.trailingAnchor.constraint(equalTo: transcriptCardView.trailingAnchor, constant: -20), + transcriptSeparator.heightAnchor.constraint(equalToConstant: 1), + + contentLabel.topAnchor.constraint(greaterThanOrEqualTo: transcriptSeparator.bottomAnchor, constant: 16), + contentLabel.leadingAnchor.constraint(equalTo: transcriptCardView.leadingAnchor, constant: 20), + contentLabel.trailingAnchor.constraint(equalTo: transcriptCardView.trailingAnchor, constant: -20), + contentLabel.centerYAnchor.constraint( + equalTo: transcriptCardView.centerYAnchor, constant: 20 + ).with(priority: .defaultHigh), + + settingsButton.topAnchor.constraint(equalTo: contentLabel.bottomAnchor, constant: 12), + settingsButton.centerXAnchor.constraint(equalTo: transcriptCardView.centerXAnchor), + settingsButton.bottomAnchor.constraint(lessThanOrEqualTo: transcriptCardView.bottomAnchor, constant: -24), + + micCardView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + micCardView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), + micCardView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -40), + micCardView.heightAnchor.constraint(equalToConstant: 200), + + micButton.widthAnchor.constraint(equalToConstant: 88), + micButton.heightAnchor.constraint(equalToConstant: 88), + micButton.centerXAnchor.constraint(equalTo: micCardView.centerXAnchor), + micButton.topAnchor.constraint(equalTo: micCardView.topAnchor, constant: 32), + + hintLabel.topAnchor.constraint(equalTo: micButton.bottomAnchor, constant: 12), + hintLabel.centerXAnchor.constraint(equalTo: micCardView.centerXAnchor), + ]) + } +} + +private extension NSLayoutConstraint { + func with(priority: UILayoutPriority) -> NSLayoutConstraint { + self.priority = priority + return self + } +} diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/VoiceExpense/VoiceExpenseViewModel.swift b/SpendLearning/SpendLearning/Source/Presentation/View/VoiceExpense/VoiceExpenseViewModel.swift new file mode 100644 index 0000000..c1cbf3d --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Presentation/View/VoiceExpense/VoiceExpenseViewModel.swift @@ -0,0 +1,192 @@ +// +// VoiceExpenseViewModel.swift +// SpendLearning +// +// Created by 김성훈 on 7/16/26. +// + +import AVFoundation +import Foundation +import Speech + +@Observable +@MainActor +final class VoiceExpenseViewModel { + + enum State { + case idle + case recording(transcript: String) + case parsed(memo: String, amount: Int, category: Category) + case error(String) + case permissionDenied(String) + } + + // MARK: - Output + private(set) var state: State = .idle + + // MARK: - Private + private let categoryUseCase: CategoryUseCaseProtocol + private let categoryPredictor: CategoryPredictor + private let parser = VoiceExpenseParser() + + private let speechRecognizer = SFSpeechRecognizer(locale: Locale(identifier: "ko-KR")) + private let audioEngine = AVAudioEngine() + private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? + private var recognitionTask: SFSpeechRecognitionTask? + + static let detailGuideText = """ + 예시: 커피 4,500원 + + 순서를 지키지 않으면 인식이 어려워요 + 금액 뒤에는 "원"을 꼭 붙여주세요 + + 신규 카테고리는 기록할수록 정확해져요 + """ + + // MARK: - Init + init(categoryUseCase: CategoryUseCaseProtocol, categoryPredictor: CategoryPredictor) { + self.categoryUseCase = categoryUseCase + self.categoryPredictor = categoryPredictor + } + + // MARK: - Input + + func startRecording() async { + if isPermissionDenied() { + state = .permissionDenied("설정에서 마이크와 음성 인식 권한을 허용해주세요") + return + } + guard hasPermissions() else { + await requestPermissionsOnly() + return + } + + state = .recording(transcript: "") + + do { + try beginRecognition() + } catch { + state = .error("녹음을 시작하지 못했어요") + } + } + + func stopRecording() async { + audioEngine.stop() + audioEngine.inputNode.removeTap(onBus: 0) + recognitionRequest?.endAudio() + + guard case .recording(let transcript) = state else { return } + await finishParsing(transcript: transcript) + } + + func reset() { + state = .idle + } + + // MARK: - Private + + private func hasPermissions() -> Bool { + let micStatus = AVAudioApplication.shared.recordPermission + let speechStatus = SFSpeechRecognizer.authorizationStatus() + return micStatus == .granted && speechStatus == .authorized + } + + private func isPermissionDenied() -> Bool { + let micDenied = AVAudioApplication.shared.recordPermission == .denied + let speechDenied = SFSpeechRecognizer.authorizationStatus() == .denied + return micDenied || speechDenied + } + + private func requestPermissionsOnly() async { + let micGranted = await withCheckedContinuation { continuation in + AVAudioApplication.requestRecordPermission { granted in + continuation.resume(returning: granted) + } + } + guard micGranted else { + state = .permissionDenied("마이크 접근 권한이 필요해요") + return + } + + let speechStatus = await withCheckedContinuation { continuation in + SFSpeechRecognizer.requestAuthorization { status in + continuation.resume(returning: status) + } + } + if speechStatus != .authorized { + state = .permissionDenied("음성 인식 권한이 필요해요") + } + // 권한을 받았어도 여기서는 녹음을 시작하지 않는다 — 사용자가 다시 눌러야 시작됨 + } + + private func beginRecognition() throws { + recognitionTask?.cancel() + recognitionTask = nil + + let audioSession = AVAudioSession.sharedInstance() + try audioSession.setCategory(.record, mode: .measurement, options: .duckOthers) + try audioSession.setActive(true, options: .notifyOthersOnDeactivation) + + let request = SFSpeechAudioBufferRecognitionRequest() + request.shouldReportPartialResults = true + if speechRecognizer?.supportsOnDeviceRecognition == true { + request.requiresOnDeviceRecognition = true + } + self.recognitionRequest = request + + let inputNode = audioEngine.inputNode + let recordingFormat = inputNode.outputFormat(forBus: 0) + inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { [weak request] buffer, _ in + request?.append(buffer) + } + + audioEngine.prepare() + try audioEngine.start() + + recognitionTask = speechRecognizer?.recognitionTask(with: request) { [weak self] result, error in + guard let self else { return } + Task { @MainActor in + if let result { + let text = result.bestTranscription.formattedString + if case .recording = self.state { + self.state = .recording(transcript: text) + } + } + if let error { + self.recognitionTask = nil + let nsError = error as NSError + if nsError.domain == "kLSRErrorDomain" && nsError.code == 201 { + self.state = .permissionDenied("설정 > 일반 > 키보드 > 받아쓰기 활성화를 켜주세요") + } else if case .recording = self.state { + self.state = .error("음성 인식에 실패했어요") + } + } + } + } + } + + private func finishParsing(transcript: String) async { + let trimmed = transcript.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + state = .error("못 알아들었어요, 다시 말씀해주세요") + return + } + + switch parser.parse(trimmed) { + case .success(let parsed): + let prediction = categoryPredictor.predict(memo: parsed.memo) + let categories = (try? await categoryUseCase.fetchCategories()) ?? [] + let matchedCategory = categories.first { $0.name == prediction.category } + ?? categories.first { !$0.isDeletable } + + guard let category = matchedCategory else { + state = .error("카테고리를 불러오지 못했어요") + return + } + state = .parsed(memo: parsed.memo, amount: parsed.amount, category: category) + + case .failure: + state = .error(Self.detailGuideText) + } + } +} diff --git a/SpendLearning/SpendLearningTests/ViewModels/PredictionViewModelTests.swift b/SpendLearning/SpendLearningTests/ViewModels/PredictionViewModelTests.swift index 2d6126d..6ba4393 100644 --- a/SpendLearning/SpendLearningTests/ViewModels/PredictionViewModelTests.swift +++ b/SpendLearning/SpendLearningTests/ViewModels/PredictionViewModelTests.swift @@ -69,16 +69,6 @@ struct PredictionViewModelTests { // MARK: - makeCumulativePrediction - @Test("오늘 날짜에 소비가 없어도 actual이 nil이 아니다") - func todayActualIsNotNilEvenWithNoExpenses() async { - let sut = PredictionViewModel(expenseUseCase: StubExpenseUseCaseForPrediction(), predictionUseCase: StubPredictionUseCase()) - - await sut.onAppear() - - let todayPoint = sut.predictionData.first(where: { $0.day == sut.today }) - #expect(todayPoint?.actual != nil) - } - @Test("누적 actual이 올바르게 계산된다") func cumulativeActualIsCorrect() async { let expenseStub = StubExpenseUseCaseForPrediction() diff --git a/SpendLearning/SpendLearningTests/ViewModels/SettingsViewModelTests.swift b/SpendLearning/SpendLearningTests/ViewModels/SettingsViewModelTests.swift index ddcd243..aec8e87 100644 --- a/SpendLearning/SpendLearningTests/ViewModels/SettingsViewModelTests.swift +++ b/SpendLearning/SpendLearningTests/ViewModels/SettingsViewModelTests.swift @@ -20,7 +20,8 @@ struct SettingsViewModelTests { let sut = SettingsViewModel( categoryUseCase: stub, expenseUseCase: StubExpenseUseCaseForSettings(), - predictionUseCase: StubPredictionUseCaseForSettings() + predictionUseCase: StubPredictionUseCaseForSettings(), + categoryPredictor: CategoryPredictor() ) await sut.loadCategories() @@ -35,7 +36,8 @@ struct SettingsViewModelTests { let sut = SettingsViewModel( categoryUseCase: stub, expenseUseCase: StubExpenseUseCaseForSettings(), - predictionUseCase: StubPredictionUseCaseForSettings() + predictionUseCase: StubPredictionUseCaseForSettings(), + categoryPredictor: CategoryPredictor() ) stub.stubbedCategories = [Category(name: "카페", emoji: "☕️")] @@ -53,7 +55,8 @@ struct SettingsViewModelTests { let sut = SettingsViewModel( categoryUseCase: stub, expenseUseCase: StubExpenseUseCaseForSettings(), - predictionUseCase: StubPredictionUseCaseForSettings() + predictionUseCase: StubPredictionUseCaseForSettings(), + categoryPredictor: CategoryPredictor() ) await sut.updateCategory(original, name: "외식", emoji: "🍖") @@ -69,7 +72,8 @@ struct SettingsViewModelTests { let sut = SettingsViewModel( categoryUseCase: stub, expenseUseCase: StubExpenseUseCaseForSettings(), - predictionUseCase: StubPredictionUseCaseForSettings() + predictionUseCase: StubPredictionUseCaseForSettings(), + categoryPredictor: CategoryPredictor() ) await sut.loadCategories() @@ -85,7 +89,8 @@ struct SettingsViewModelTests { let sut = SettingsViewModel( categoryUseCase: stub, expenseUseCase: StubExpenseUseCaseForSettings(), - predictionUseCase: StubPredictionUseCaseForSettings() + predictionUseCase: StubPredictionUseCaseForSettings(), + categoryPredictor: CategoryPredictor() ) stub.stubbedCategories = [ Category(name: "식비", emoji: "🍚"), @@ -106,7 +111,8 @@ struct SettingsViewModelTests { let sut = SettingsViewModel( categoryUseCase: stub, expenseUseCase: StubExpenseUseCaseForSettings(), - predictionUseCase: StubPredictionUseCaseForSettings() + predictionUseCase: StubPredictionUseCaseForSettings(), + categoryPredictor: CategoryPredictor() ) await sut.reorderCategories([first, second]) @@ -122,7 +128,8 @@ struct SettingsViewModelTests { let sut = SettingsViewModel( categoryUseCase: stub, expenseUseCase: StubExpenseUseCaseForSettings(), - predictionUseCase: StubPredictionUseCaseForSettings() + predictionUseCase: StubPredictionUseCaseForSettings(), + categoryPredictor: CategoryPredictor() ) await sut.loadCategories() @@ -138,7 +145,8 @@ struct SettingsViewModelTests { let sut = SettingsViewModel( categoryUseCase: stub, expenseUseCase: StubExpenseUseCaseForSettings(), - predictionUseCase: StubPredictionUseCaseForSettings() + predictionUseCase: StubPredictionUseCaseForSettings(), + categoryPredictor: CategoryPredictor() ) await sut.loadCategories()