From 78888f953d4c7eda3a7be96c32270911dd5793fe Mon Sep 17 00:00:00 2001 From: snughnu Date: Thu, 16 Jul 2026 00:28:34 +0900 Subject: [PATCH 01/15] =?UTF-8?q?chore:=20=EC=9D=8C=EC=84=B1=20=EC=B9=B4?= =?UTF-8?q?=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EB=B6=84=EB=A5=98=20=ED=95=99?= =?UTF-8?q?=EC=8A=B5=EC=9A=A9=20=EC=8B=9C=EB=93=9C=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MLTraining/README.md | 11 + MLTraining/seed_data.json | 2002 +++++++++++++++++++++++++++++++++++++ 2 files changed, 2013 insertions(+) create mode 100644 MLTraining/README.md create mode 100644 MLTraining/seed_data.json 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": "기타" + } +] From ce91a3f73b1ed0c51dbc8998d2ad438bab0ee7aa Mon Sep 17 00:00:00 2001 From: snughnu Date: Thu, 16 Jul 2026 00:37:34 +0900 Subject: [PATCH 02/15] =?UTF-8?q?feat:=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20=EB=B6=84=EB=A5=98=20kNN=20=EB=AA=A8=EB=8D=B8=20?= =?UTF-8?q?=ED=95=99=EC=8A=B5=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MLTraining/train.py | 66 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 MLTraining/train.py diff --git a/MLTraining/train.py b/MLTraining/train.py new file mode 100644 index 0000000..24c2d90 --- /dev/null +++ b/MLTraining/train.py @@ -0,0 +1,66 @@ +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 = "기타" + + +def build_vocabulary(memos): + """시드 데이터에 등장하는 모든 글자를 모아 정렬된 vocabulary를 만든다.""" + chars = set() + for memo in memos: + chars.update(memo) + return sorted(chars) + + +def vectorize(memo, vocabulary): + """memo 문자열을 vocabulary 기준 글자 빈도 벡터로 변환한다.""" + counts = Counter(memo) + return [float(counts.get(ch, 0)) for ch 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() From 8720c484ea8471cf5bf4512b090e94aba80aaaa0 Mon Sep 17 00:00:00 2001 From: snughnu Date: Thu, 16 Jul 2026 00:55:51 +0900 Subject: [PATCH 03/15] =?UTF-8?q?fix:=20=EC=9E=90=EB=AA=A8=20=EB=B2=A1?= =?UTF-8?q?=ED=84=B0=ED=99=94=20=EC=8B=9C=20=EC=A2=85=EC=84=B1=EC=9D=B4=20?= =?UTF-8?q?=EC=B4=88=EC=84=B1=EA=B3=BC=20=EA=B2=B9=EC=B9=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MLTraining/train.py | 51 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 43 insertions(+), 8 deletions(-) diff --git a/MLTraining/train.py b/MLTraining/train.py index 24c2d90..c8871c2 100644 --- a/MLTraining/train.py +++ b/MLTraining/train.py @@ -12,19 +12,54 @@ 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를 만든다.""" - chars = set() + """시드 데이터에 등장하는 모든 자모/문자를 모아 정렬된 vocabulary를 만든다.""" + tokens = set() for memo in memos: - chars.update(memo) - return sorted(chars) + tokens.update(tokenize(memo)) + return sorted(tokens) def vectorize(memo, vocabulary): - """memo 문자열을 vocabulary 기준 글자 빈도 벡터로 변환한다.""" - counts = Counter(memo) - return [float(counts.get(ch, 0)) for ch in vocabulary] + """memo 문자열을 vocabulary 기준 자모 빈도 벡터로 변환한다.""" + counts = Counter(tokenize(memo)) + return [float(counts.get(token, 0)) for token in vocabulary] def main(): @@ -35,7 +70,7 @@ def main(): labels = [item["category"] for item in seed_data] vocabulary = build_vocabulary(memos) - print(f"Vocabulary 크기: {len(vocabulary)}자") + print(f"Vocabulary 크기: {len(vocabulary)}개 (자모/문자 단위)") vectors = [vectorize(memo, vocabulary) for memo in memos] From 3d023e3f567de1762a8ad14a97797ad6a57a3380 Mon Sep 17 00:00:00 2001 From: snughnu Date: Thu, 16 Jul 2026 01:29:50 +0900 Subject: [PATCH 04/15] =?UTF-8?q?feat:=20=EC=A7=80=EC=B6=9C=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=20=ED=99=94=EB=A9=B4=EC=9D=84=20=EC=B9=B4=EB=93=9C=20?= =?UTF-8?q?UI=EB=A1=9C=20=EC=9E=AC=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../View/Home/HomeViewController.swift | 28 ++- .../CategorySelectViewController.swift | 6 +- .../ExpenseInputViewController.swift | 201 ++++++++++++++---- .../View/NewExpense/NewExpenseViewModel.swift | 14 +- 4 files changed, 188 insertions(+), 61 deletions(-) diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/Home/HomeViewController.swift b/SpendLearning/SpendLearning/Source/Presentation/View/Home/HomeViewController.swift index 4a332ad..c646eda 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/Home/HomeViewController.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/Home/HomeViewController.swift @@ -455,14 +455,24 @@ private extension HomeViewController { } func presentAddExpense(expense: Expense? = nil) { - let newExpenseViewModel = NewExpenseViewModel( - expenseUseCase: viewModel.expenseUseCase, - categoryUseCase: categoryUseCase, - date: viewModel.selectedDate, - editingExpense: expense - ) - let categorySelectVC = CategorySelectViewController(viewModel: newExpenseViewModel) - categorySelectVC.modalPresentationStyle = .fullScreen - present(categorySelectVC, animated: true) + Task { + var defaultCategory: Category? = nil + if expense == nil { + defaultCategory = try? await categoryUseCase.fetchCategories().first { !$0.isDeletable } + } + + let newExpenseViewModel = NewExpenseViewModel( + expenseUseCase: viewModel.expenseUseCase, + categoryUseCase: categoryUseCase, + date: viewModel.selectedDate, + editingExpense: expense, + prefillCategory: defaultCategory + ) + await newExpenseViewModel.loadCategories() + + let inputVC = ExpenseInputViewController(viewModel: newExpenseViewModel) + inputVC.modalPresentationStyle = .fullScreen + present(inputVC, animated: true) + } } } diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/NewExpense/CategorySelectViewController.swift b/SpendLearning/SpendLearning/Source/Presentation/View/NewExpense/CategorySelectViewController.swift index 44f53a6..15c7e26 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/NewExpense/CategorySelectViewController.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/NewExpense/CategorySelectViewController.swift @@ -18,6 +18,8 @@ final class CategorySelectViewController: UIViewController { private let viewModel: NewExpenseViewModel private var cancellables = Set() + 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..ed3649a 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/NewExpense/ExpenseInputViewController.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/NewExpense/ExpenseInputViewController.swift @@ -11,22 +11,29 @@ 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() // MARK: - Init init(viewModel: NewExpenseViewModel) { @@ -43,6 +50,7 @@ final class ExpenseInputViewController: UIViewController { super.viewDidLoad() view.backgroundColor = .DesignSystem.background setup() + bindSelectedCategory() } override func viewWillAppear(_ animated: Bool) { @@ -57,7 +65,7 @@ private extension ExpenseInputViewController { @objc func didTapSave() { Task { await viewModel.didSaveExpense() - presentingViewController?.presentingViewController?.dismiss(animated: true) + dismiss(animated: true) } } @@ -65,14 +73,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 +105,12 @@ private extension ExpenseInputViewController { func setup() { setupNavigationBar() - setupSubviews() + setupCardView() + setupCategoryRow() + setupAmountField() + setupMemoField() setupConstraints() + updateCategoryDisplay() } func setupNavigationBar() { @@ -103,37 +122,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 +201,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 } } From 10c8914c9d42c2cc13bd0a82fe933344675b4550 Mon Sep 17 00:00:00 2001 From: snughnu Date: Thu, 16 Jul 2026 01:35:27 +0900 Subject: [PATCH 05/15] =?UTF-8?q?feat:=20=EC=9D=8C=EC=84=B1=20=ED=85=8D?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EC=97=90=EC=84=9C=20=EB=A9=94=EB=AA=A8/?= =?UTF-8?q?=EA=B8=88=EC=95=A1=EC=9D=84=20=EB=B6=84=EB=A6=AC=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=ED=8C=8C=EC=84=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Domain/Parsers/VoiceExpenseParser.swift | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 SpendLearning/SpendLearning/Source/Domain/Parsers/VoiceExpenseParser.swift 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.. Date: Thu, 16 Jul 2026 02:02:51 +0900 Subject: [PATCH 06/15] =?UTF-8?q?feat:=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20=EC=98=88=EC=B8=A1=EC=9A=A9=20kNN=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=EA=B3=BC=20vocabulary=EB=A5=BC=20=EC=95=B1=20?= =?UTF-8?q?=EB=B2=88=EB=93=A4=EC=97=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Resource/CategoryClassifier.mlmodel | Bin 0 -> 188270 bytes .../SpendLearning/Resource/vocabulary.json | 92 ++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 SpendLearning/SpendLearning/Resource/CategoryClassifier.mlmodel create mode 100644 SpendLearning/SpendLearning/Resource/vocabulary.json diff --git a/SpendLearning/SpendLearning/Resource/CategoryClassifier.mlmodel b/SpendLearning/SpendLearning/Resource/CategoryClassifier.mlmodel new file mode 100644 index 0000000000000000000000000000000000000000..84558c7eec8cc73623be0d2c06f0a51cc1c5092d GIT binary patch literal 188270 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 Date: Thu, 16 Jul 2026 02:06:59 +0900 Subject: [PATCH 07/15] =?UTF-8?q?feat:=20CoreML=20kNN=20=EB=AA=A8=EB=8D=B8?= =?UTF-8?q?=EC=9D=84=20=ED=99=9C=EC=9A=A9=ED=95=9C=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20=EC=98=88=EC=B8=A1=EA=B8=B0=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 --- .../Data/Predictors/CategoryPredictor.swift | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 SpendLearning/SpendLearning/Source/Data/Predictors/CategoryPredictor.swift diff --git a/SpendLearning/SpendLearning/Source/Data/Predictors/CategoryPredictor.swift b/SpendLearning/SpendLearning/Source/Data/Predictors/CategoryPredictor.swift new file mode 100644 index 0000000..f5d8f28 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Data/Predictors/CategoryPredictor.swift @@ -0,0 +1,159 @@ +// +// 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: - 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 bundledModelURL = Bundle.main.url(forResource: "CategoryClassifier", withExtension: "mlmodel"), + let compiledURL = try? MLModel.compileModel(at: bundledModelURL) else { + self.model = try? MLModel(contentsOf: writableURL) + return + } + + try? fileManager.removeItem(at: writableURL) + try? fileManager.copyItem(at: compiledURL, 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 + } +} From d648b3e9a05eb03cde9a9c181d2a8b5e4670fbd3 Mon Sep 17 00:00:00 2001 From: snughnu Date: Thu, 16 Jul 2026 02:16:42 +0900 Subject: [PATCH 08/15] =?UTF-8?q?feat:=20ExpenseInputViewController?= =?UTF-8?q?=EC=97=90=20=EC=A0=80=EC=9E=A5=20=EC=99=84=EB=A3=8C=20=EC=BD=9C?= =?UTF-8?q?=EB=B0=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../View/NewExpense/ExpenseInputViewController.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/NewExpense/ExpenseInputViewController.swift b/SpendLearning/SpendLearning/Source/Presentation/View/NewExpense/ExpenseInputViewController.swift index ed3649a..9da033b 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/NewExpense/ExpenseInputViewController.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/NewExpense/ExpenseInputViewController.swift @@ -35,6 +35,10 @@ final class ExpenseInputViewController: UIViewController { private let viewModel: NewExpenseViewModel private var cancellables = Set() + /// 저장이 성공적으로 끝난 직후 호출 + /// 음성추가 흐름에서 CategoryPredictor.update를 트리거하는 용도로 사용 + var onDidSave: (() -> Void)? + // MARK: - Init init(viewModel: NewExpenseViewModel) { self.viewModel = viewModel @@ -65,6 +69,7 @@ private extension ExpenseInputViewController { @objc func didTapSave() { Task { await viewModel.didSaveExpense() + onDidSave?() dismiss(animated: true) } } From 5e6a7a698282d343bf664ffbf60a7a84f8d75050 Mon Sep 17 00:00:00 2001 From: snughnu Date: Thu, 16 Jul 2026 03:40:27 +0900 Subject: [PATCH 09/15] =?UTF-8?q?feat:=20=EC=9D=8C=EC=84=B1=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=ED=83=AD=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20CoreML?= =?UTF-8?q?=20=EB=AA=A8=EB=8D=B8=20=EB=A1=9C=EB=94=A9=20=EB=B2=84=EA=B7=B8?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SpendLearning/Resource/Info.plist | 4 + .../Source/App/SceneDelegate.swift | 19 +- .../Data/Predictors/CategoryPredictor.swift | 5 +- .../VoiceExpenseViewController.swift | 265 ++++++++++++++++++ .../VoiceExpense/VoiceExpenseViewModel.swift | 191 +++++++++++++ 5 files changed, 480 insertions(+), 4 deletions(-) create mode 100644 SpendLearning/SpendLearning/Source/Presentation/View/VoiceExpense/VoiceExpenseViewController.swift create mode 100644 SpendLearning/SpendLearning/Source/Presentation/View/VoiceExpense/VoiceExpenseViewModel.swift 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/Source/App/SceneDelegate.swift b/SpendLearning/SpendLearning/Source/App/SceneDelegate.swift index 4b90f73..ffdfd1a 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)) @@ -73,7 +90,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 index f5d8f28..ba62cf5 100644 --- a/SpendLearning/SpendLearning/Source/Data/Predictors/CategoryPredictor.swift +++ b/SpendLearning/SpendLearning/Source/Data/Predictors/CategoryPredictor.swift @@ -98,14 +98,13 @@ private extension CategoryPredictor { let writableURL = supportDir.appendingPathComponent("CategoryClassifier.mlmodelc") self.modelURL = writableURL - guard let bundledModelURL = Bundle.main.url(forResource: "CategoryClassifier", withExtension: "mlmodel"), - let compiledURL = try? MLModel.compileModel(at: bundledModelURL) else { + 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: compiledURL, to: writableURL) + try? fileManager.copyItem(at: bundledCompiledURL, to: writableURL) self.model = try? MLModel(contentsOf: writableURL) } 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..935f56e --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Presentation/View/VoiceExpense/VoiceExpenseViewController.swift @@ -0,0 +1,265 @@ +// +// 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 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() } + } +} + +// MARK: - Render +private extension VoiceExpenseViewController { + + func render() { + 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 = .systemRed + micButton.transform = .identity + } + } + + 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 + + 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].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), + contentLabel.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..8820bab --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Presentation/View/VoiceExpense/VoiceExpenseViewModel.swift @@ -0,0 +1,191 @@ +// +// 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) + } + + // 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 { + 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 requestPermissionsOnly() async { + let micGranted = await withCheckedContinuation { continuation in + AVAudioApplication.requestRecordPermission { granted in + continuation.resume(returning: granted) + } + } + guard micGranted else { + state = .error("마이크 접근 권한이 필요해요. 설정에서 허용해주세요") + return + } + + let speechStatus = await withCheckedContinuation { continuation in + SFSpeechRecognizer.requestAuthorization { status in + continuation.resume(returning: status) + } + } + if speechStatus != .authorized { + state = .error("음성 인식 권한이 필요해요. 설정에서 허용해주세요") + } + // 권한을 받았어도 여기서는 녹음을 시작하지 않는다 — 사용자가 다시 눌러야 시작됨 + } + + private func requestPermissions() async -> Bool { + let micGranted = await withCheckedContinuation { continuation in + AVAudioApplication.requestRecordPermission { granted in + continuation.resume(returning: granted) + } + } + guard micGranted else { return false } + + let speechStatus = await withCheckedContinuation { continuation in + SFSpeechRecognizer.requestAuthorization { status in + continuation.resume(returning: status) + } + } + return speechStatus == .authorized + } + + 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 error != nil { + self.recognitionTask = nil + } + } + } + } + + 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) + } + } +} From a07bda38ea5232cc5f26856dbe1ea5bf51fd88d4 Mon Sep 17 00:00:00 2001 From: snughnu Date: Thu, 16 Jul 2026 03:47:57 +0900 Subject: [PATCH 10/15] =?UTF-8?q?feat:=20=EB=A7=88=EC=9D=B4=ED=81=AC/?= =?UTF-8?q?=EC=9D=8C=EC=84=B1=EC=9D=B8=EC=8B=9D=20=EA=B6=8C=ED=95=9C=20?= =?UTF-8?q?=EA=B1=B0=EB=B6=80=20=EC=8B=9C=20=EC=84=A4=EC=A0=95=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=9C=A0=EB=8F=84=ED=95=98=EB=8A=94=20=EC=95=88?= =?UTF-8?q?=EB=82=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../VoiceExpenseViewController.swift | 28 ++++++++++++- .../VoiceExpense/VoiceExpenseViewModel.swift | 41 ++++++++++--------- 2 files changed, 47 insertions(+), 22 deletions(-) diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/VoiceExpense/VoiceExpenseViewController.swift b/SpendLearning/SpendLearning/Source/Presentation/View/VoiceExpense/VoiceExpenseViewController.swift index 935f56e..8805701 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/VoiceExpense/VoiceExpenseViewController.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/VoiceExpense/VoiceExpenseViewController.swift @@ -16,6 +16,7 @@ final class VoiceExpenseViewController: UIViewController { 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() @@ -97,12 +98,19 @@ private extension VoiceExpenseViewController { @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 @@ -121,10 +129,17 @@ private extension VoiceExpenseViewController { 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 } } @@ -185,6 +200,12 @@ private extension VoiceExpenseViewController { 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 @@ -200,7 +221,7 @@ private extension VoiceExpenseViewController { hintLabel.textColor = .DesignSystem.subtitle hintLabel.textAlignment = .center - [fixedGuideLabel, transcriptSeparator, contentLabel].forEach { + [fixedGuideLabel, transcriptSeparator, contentLabel, settingsButton].forEach { $0.translatesAutoresizingMaskIntoConstraints = false transcriptCardView.addSubview($0) } @@ -239,7 +260,10 @@ private extension VoiceExpenseViewController { contentLabel.centerYAnchor.constraint( equalTo: transcriptCardView.centerYAnchor, constant: 20 ).with(priority: .defaultHigh), - contentLabel.bottomAnchor.constraint(lessThanOrEqualTo: transcriptCardView.bottomAnchor, constant: -24), + + 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), diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/VoiceExpense/VoiceExpenseViewModel.swift b/SpendLearning/SpendLearning/Source/Presentation/View/VoiceExpense/VoiceExpenseViewModel.swift index 8820bab..c1cbf3d 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/VoiceExpense/VoiceExpenseViewModel.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/VoiceExpense/VoiceExpenseViewModel.swift @@ -18,6 +18,7 @@ final class VoiceExpenseViewModel { case recording(transcript: String) case parsed(memo: String, amount: Int, category: Category) case error(String) + case permissionDenied(String) } // MARK: - Output @@ -39,7 +40,7 @@ final class VoiceExpenseViewModel { 순서를 지키지 않으면 인식이 어려워요 금액 뒤에는 "원"을 꼭 붙여주세요 - 신규 카테고리는 쓸수록 정확해져요 + 신규 카테고리는 기록할수록 정확해져요 """ // MARK: - Init @@ -51,6 +52,10 @@ final class VoiceExpenseViewModel { // MARK: - Input func startRecording() async { + if isPermissionDenied() { + state = .permissionDenied("설정에서 마이크와 음성 인식 권한을 허용해주세요") + return + } guard hasPermissions() else { await requestPermissionsOnly() return @@ -86,6 +91,12 @@ final class VoiceExpenseViewModel { 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 @@ -93,7 +104,7 @@ final class VoiceExpenseViewModel { } } guard micGranted else { - state = .error("마이크 접근 권한이 필요해요. 설정에서 허용해주세요") + state = .permissionDenied("마이크 접근 권한이 필요해요") return } @@ -103,27 +114,11 @@ final class VoiceExpenseViewModel { } } if speechStatus != .authorized { - state = .error("음성 인식 권한이 필요해요. 설정에서 허용해주세요") + state = .permissionDenied("음성 인식 권한이 필요해요") } // 권한을 받았어도 여기서는 녹음을 시작하지 않는다 — 사용자가 다시 눌러야 시작됨 } - private func requestPermissions() async -> Bool { - let micGranted = await withCheckedContinuation { continuation in - AVAudioApplication.requestRecordPermission { granted in - continuation.resume(returning: granted) - } - } - guard micGranted else { return false } - - let speechStatus = await withCheckedContinuation { continuation in - SFSpeechRecognizer.requestAuthorization { status in - continuation.resume(returning: status) - } - } - return speechStatus == .authorized - } - private func beginRecognition() throws { recognitionTask?.cancel() recognitionTask = nil @@ -157,8 +152,14 @@ final class VoiceExpenseViewModel { self.state = .recording(transcript: text) } } - if error != nil { + 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("음성 인식에 실패했어요") + } } } } From 4a1d369cc1fbab8eb1c1748f8304b974cc98ac97 Mon Sep 17 00:00:00 2001 From: snughnu Date: Thu, 16 Jul 2026 03:59:40 +0900 Subject: [PATCH 11/15] =?UTF-8?q?fix:=20=EC=A0=84=EC=B2=B4=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20=EC=8B=9C=20=EC=9D=8C=EC=84=B1=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20CoreML=20=ED=95=99=EC=8A=B5=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=EB=8F=84=20=ED=95=A8=EA=BB=98=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SpendLearning/Source/App/SceneDelegate.swift | 3 ++- .../Data/Predictors/CategoryPredictor.swift | 15 +++++++++++++++ .../View/Settings/SettingsViewController.swift | 6 ++++-- .../View/Settings/SettingsViewModel.swift | 7 +++++-- 4 files changed, 26 insertions(+), 5 deletions(-) diff --git a/SpendLearning/SpendLearning/Source/App/SceneDelegate.swift b/SpendLearning/SpendLearning/Source/App/SceneDelegate.swift index ffdfd1a..8bcac0f 100644 --- a/SpendLearning/SpendLearning/Source/App/SceneDelegate.swift +++ b/SpendLearning/SpendLearning/Source/App/SceneDelegate.swift @@ -81,7 +81,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { let settingsViewController = SettingsViewController( categoryUseCase: categoryUseCase, expenseUseCase: expenseUseCase, - predictionUseCase: predictionUseCase + predictionUseCase: predictionUseCase, + categoryPredictor: categoryPredictor ) settingsViewController.tabBarItem = UITabBarItem( title: "설정", diff --git a/SpendLearning/SpendLearning/Source/Data/Predictors/CategoryPredictor.swift b/SpendLearning/SpendLearning/Source/Data/Predictors/CategoryPredictor.swift index ba62cf5..f4a5ec7 100644 --- a/SpendLearning/SpendLearning/Source/Data/Predictors/CategoryPredictor.swift +++ b/SpendLearning/SpendLearning/Source/Data/Predictors/CategoryPredictor.swift @@ -82,6 +82,21 @@ final class CategoryPredictor { } } } + + // 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 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 From f24947b620d2d9645380a19706437116bb8d0fe6 Mon Sep 17 00:00:00 2001 From: snughnu Date: Thu, 16 Jul 2026 04:05:50 +0900 Subject: [PATCH 12/15] =?UTF-8?q?fix:=20=EC=A7=80=EC=B6=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=EC=9D=B4=20=EC=97=86=EC=96=B4=EB=8F=84=20=EC=8B=A4?= =?UTF-8?q?=EC=A0=9C=20=EC=86=8C=EB=B9=84=20=EB=9D=BC=EC=9D=B8=EC=9D=B4=20?= =?UTF-8?q?=EA=B7=B8=EB=A0=A4=EC=A7=80=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Presentation/View/Prediction/PredictionViewModel.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/Prediction/PredictionViewModel.swift b/SpendLearning/SpendLearning/Source/Presentation/View/Prediction/PredictionViewModel.swift index 4e52041..f744f69 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/Prediction/PredictionViewModel.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/Prediction/PredictionViewModel.swift @@ -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 ) } From 8833ebc53d4173f42fefee438195cb46056849cb Mon Sep 17 00:00:00 2001 From: snughnu Date: Thu, 16 Jul 2026 04:14:41 +0900 Subject: [PATCH 13/15] =?UTF-8?q?fix:=20UI=20=EC=9D=BC=EB=B6=80=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../View/Prediction/PredictionStatusCardView.swift | 4 ++++ .../Presentation/View/Prediction/PredictionViewModel.swift | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) 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 f744f69..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 From 53e8d4c6f0dc142bfd36fb06b508277c0ba29caa Mon Sep 17 00:00:00 2001 From: snughnu Date: Thu, 16 Jul 2026 04:21:21 +0900 Subject: [PATCH 14/15] =?UTF-8?q?fix:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SpendLearning.xcodeproj/project.pbxproj | 1 + .../ViewModels/SettingsViewModelTests.swift | 24 ++++++++++++------- 2 files changed, 17 insertions(+), 8 deletions(-) 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/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() From 9359df6308d2911aa2fd4c1bf9b85c22cf38c40e Mon Sep 17 00:00:00 2001 From: snughnu Date: Thu, 16 Jul 2026 04:31:16 +0900 Subject: [PATCH 15/15] =?UTF-8?q?fix:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ViewModels/PredictionViewModelTests.swift | 10 ---------- 1 file changed, 10 deletions(-) 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()