From 28670b69a1e5edb4be145e8950728300cec08696 Mon Sep 17 00:00:00 2001 From: sb Date: Sun, 5 Jul 2026 14:18:50 +0900 Subject: [PATCH] =?UTF-8?q?feat(ai):=20=EC=98=81=EC=88=98=EC=A6=9D=20?= =?UTF-8?q?=EC=82=AC=EC=A7=84=20=E2=86=92=20Claude=20Vision=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /account/ocr/receipt-ai — 이미지를 Haiku 비전에 보내 금액·상호·날짜·분류·계좌를 한 번에 추출. AI 미설정/실패 시 recognized=false → 프론트가 기존 Google Vision OCR 로 폴백. Claude 호출/응답파싱 공통화(callClaude), 프롬프트 힌트주입 공통화(withHints). Co-Authored-By: Claude Opus 4.8 --- .../web/account/controller/OcrController.java | 23 ++++- .../sb/web/account/service/AiEntryParser.java | 99 ++++++++++++------- 2 files changed, 88 insertions(+), 34 deletions(-) diff --git a/src/main/java/com/sb/web/account/controller/OcrController.java b/src/main/java/com/sb/web/account/controller/OcrController.java index 3b5ca9d..8ba9642 100644 --- a/src/main/java/com/sb/web/account/controller/OcrController.java +++ b/src/main/java/com/sb/web/account/controller/OcrController.java @@ -1,5 +1,7 @@ package com.sb.web.account.controller; +import com.sb.web.account.dto.ParsedEntryResponse; +import com.sb.web.account.service.AiEntryParser; import com.sb.web.account.service.VisionOcrService; import com.sb.web.auth.dto.SessionUser; import com.sb.web.auth.web.AuthInterceptor; @@ -14,11 +16,14 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; +import java.time.LocalDate; +import java.util.List; import java.util.Map; /** * 영수증 OCR API (/api/account/ocr/** — 로그인 필요). - * 이미지 업로드 → Google Vision 으로 텍스트 추출 → 프론트에서 금액·날짜·상호 파싱. + * - /receipt : Google Vision 텍스트 추출(프론트 규칙 파서용, 폴백) + * - /receipt-ai: Claude Vision 으로 영수증을 구조화(금액·상호·날짜·분류·계좌). AI 미설정/실패 시 recognized=false. */ @RestController @RequestMapping("/api/account/ocr") @@ -26,6 +31,7 @@ import java.util.Map; public class OcrController { private final VisionOcrService visionOcrService; + private final AiEntryParser aiEntryParser; @PostMapping("/receipt") public Map receipt( @@ -37,4 +43,19 @@ public class OcrController { String text = visionOcrService.recognize(image.getBytes()); return Map.of("text", text); } + + /** 영수증 이미지 → AI 구조화. AI 미설정/실패/미인식이면 recognized=false 로 반환(프론트가 /receipt 폴백). */ + @PostMapping("/receipt-ai") + public ParsedEntryResponse receiptAi( + @RequestParam("image") MultipartFile image, + @RequestParam(value = "categoryHints", required = false) List categoryHints, + @RequestParam(value = "walletHints", required = false) List walletHints, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) throws IOException { + if (image == null || image.isEmpty()) { + throw new ApiException(HttpStatus.BAD_REQUEST, "영수증 이미지를 첨부하세요."); + } + return aiEntryParser + .parseReceipt(image.getBytes(), image.getContentType(), LocalDate.now(), categoryHints, walletHints) + .orElseGet(() -> ParsedEntryResponse.builder().recognized(false).build()); + } } diff --git a/src/main/java/com/sb/web/account/service/AiEntryParser.java b/src/main/java/com/sb/web/account/service/AiEntryParser.java index 214f78b..d75c633 100644 --- a/src/main/java/com/sb/web/account/service/AiEntryParser.java +++ b/src/main/java/com/sb/web/account/service/AiEntryParser.java @@ -10,6 +10,7 @@ import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; import java.time.LocalDate; +import java.util.Base64; import java.util.List; import java.util.Map; import java.util.Optional; @@ -57,34 +58,71 @@ public class AiEntryParser { return Optional.empty(); } log.info("[ai-parse] 요청 수신 — text='{}'", text); - try { - String catList = (categoryHints == null || categoryHints.isEmpty()) ? "(없음)" : String.join(", ", categoryHints); - String walletList = (walletHints == null || walletHints.isEmpty()) ? "(없음)" : String.join(", ", walletHints); - String system = (""" - 너는 한국어 가계부 입력 도우미다. 사용자가 자연어로 쓴 한 줄 지출/수입 메모를 구조화한다. - 반드시 아래 JSON 객체 '하나만' 출력한다(마크다운/설명 금지): - {"recognized":boolean,"type":"INCOME"|"EXPENSE","amount":정수(원),"merchant":"문자열","date":"YYYY-MM-DD","category":"문자열","wallet":"문자열"} - 규칙: - - amount: 원 단위 정수. "5천원"=5000, "1만2천"=12000, "3만5천원"=35000, "천원"=1000. - - type: 급여/월급/수입/입금/환불/이자/용돈 등은 INCOME, 그 외 소비는 EXPENSE(기본). - - merchant: 가맹점/항목명(예: 스타벅스, 점심, 택시). 없으면 빈 문자열. - - date: 오늘=__TODAY__ 기준 상대표현 해석(오늘/어제/그저께/엊그제 등). 명시 없으면 오늘. - - category: 거래 내용에 가장 알맞은 분류를 아래 '분류 후보'에서 정확히 하나 고른다(예: 라면·점심→식비, 택시→교통, 커피→카페). 후보에 마땅한 게 없으면 빈 문자열. 후보에 없는 값을 지어내지 말 것. - - wallet: 텍스트에 결제수단/계좌가 명시(예: 삼성카드, 현금, 신한, 체크카드)됐고 아래 '결제수단 후보'에 있으면 그 이름을 정확히 넣는다. 언급 없거나 후보에 없으면 빈 문자열. - - 금액을 못 찾거나 거래가 아니면 recognized=false. - 분류 후보: __CATS__ - 결제수단 후보: __WALLETS__ - """) - .replace("__TODAY__", today.toString()) - .replace("__CATS__", catList) - .replace("__WALLETS__", walletList); + String system = withHints(""" + 너는 한국어 가계부 입력 도우미다. 사용자가 자연어로 쓴 한 줄 지출/수입 메모를 구조화한다. + 반드시 아래 JSON 객체 '하나만' 출력한다(마크다운/설명 금지): + {"recognized":boolean,"type":"INCOME"|"EXPENSE","amount":정수(원),"merchant":"문자열","date":"YYYY-MM-DD","category":"문자열","wallet":"문자열"} + 규칙: + - amount: 원 단위 정수. "5천원"=5000, "1만2천"=12000, "3만5천원"=35000, "천원"=1000. + - type: 급여/월급/수입/입금/환불/이자/용돈 등은 INCOME, 그 외 소비는 EXPENSE(기본). + - merchant: 가맹점/항목명(예: 스타벅스, 점심, 택시). 없으면 빈 문자열. + - date: 오늘=__TODAY__ 기준 상대표현 해석(오늘/어제/그저께/엊그제 등). 명시 없으면 오늘. + - category: 거래 내용에 가장 알맞은 분류를 아래 '분류 후보'에서 정확히 하나 고른다(예: 라면·점심→식비, 택시→교통, 커피→카페). 후보에 마땅한 게 없으면 빈 문자열. 후보에 없는 값을 지어내지 말 것. + - wallet: 텍스트에 결제수단/계좌가 명시(예: 삼성카드, 현금, 신한, 체크카드)됐고 아래 '결제수단 후보'에 있으면 그 이름을 정확히 넣는다. 언급 없거나 후보에 없으면 빈 문자열. + - 금액을 못 찾거나 거래가 아니면 recognized=false. + 분류 후보: __CATS__ + 결제수단 후보: __WALLETS__ + """, today, categoryHints, walletHints); + return callClaude(system, text, 300, today, categoryHints, walletHints); + } + /** 영수증 이미지 → 구조화(Claude Vision). 실패/미설정 시 empty → 호출부가 기존 OCR 로 폴백. */ + public Optional parseReceipt(byte[] image, String mediaType, LocalDate today, + List categoryHints, List walletHints) { + if (!enabled() || image == null || image.length == 0) { + log.info("[ai-parse] 영수증 건너뜀 — enabled={}, empty={}", enabled(), (image == null || image.length == 0)); + return Optional.empty(); + } + log.info("[ai-parse] 영수증 수신 — {} bytes, media={}", image.length, mediaType); + String system = withHints(""" + 너는 한국어 영수증 판독기다. 첨부된 영수증 이미지를 보고 거래 하나로 구조화한다. + 반드시 아래 JSON 객체 '하나만' 출력한다(마크다운/설명 금지): + {"recognized":boolean,"type":"INCOME"|"EXPENSE","amount":정수(원),"merchant":"문자열","date":"YYYY-MM-DD","category":"문자열","wallet":"문자열"} + 규칙: + - amount: 최종 결제/합계 금액(원 단위 정수, 부가세 포함 총액). + - type: 영수증은 대부분 EXPENSE. 환불 영수증이면 INCOME. + - merchant: 상호명(가맹점). + - date: 영수증의 거래일(YYYY-MM-DD). 안 보이면 오늘=__TODAY__. + - category: 품목/업종에 가장 알맞은 분류를 아래 '분류 후보'에서 정확히 하나 고른다. 없으면 빈 문자열. 후보에 없는 값 지어내지 말 것. + - wallet: 영수증에 카드사/결제수단이 보이고 아래 '결제수단 후보'에 있으면 그 이름. 아니면 빈 문자열. + - 영수증이 아니거나 금액을 못 읽으면 recognized=false. + 분류 후보: __CATS__ + 결제수단 후보: __WALLETS__ + """, today, categoryHints, walletHints); + String base64 = Base64.getEncoder().encodeToString(image); + String mt = (mediaType == null || mediaType.isBlank()) ? "image/jpeg" : mediaType; + List content = List.of( + Map.of("type", "image", "source", Map.of("type", "base64", "media_type", mt, "data", base64)), + Map.of("type", "text", "text", "이 영수증을 위 스키마로 구조화해줘.")); + return callClaude(system, content, 400, today, categoryHints, walletHints); + } + + /** 분류·계좌 후보와 오늘 날짜를 시스템 프롬프트 템플릿에 주입. */ + private String withHints(String body, LocalDate today, List categoryHints, List walletHints) { + String catList = (categoryHints == null || categoryHints.isEmpty()) ? "(없음)" : String.join(", ", categoryHints); + String walletList = (walletHints == null || walletHints.isEmpty()) ? "(없음)" : String.join(", ", walletHints); + return body.replace("__TODAY__", today.toString()).replace("__CATS__", catList).replace("__WALLETS__", walletList); + } + + /** Claude Messages 호출 → 응답 JSON 파싱 → 결과. 후보에 없는 분류·계좌는 버림. 실패/미인식 시 empty. */ + private Optional callClaude(String system, Object userContent, int maxTokens, + LocalDate today, List categoryHints, List walletHints) { + try { Map reqBody = Map.of( "model", model, - "max_tokens", 300, + "max_tokens", maxTokens, "system", system, - "messages", List.of(Map.of("role", "user", "content", text))); - + "messages", List.of(Map.of("role", "user", "content", userContent))); JsonNode resp = rest.post() .uri("/v1/messages") .header("x-api-key", apiKey) @@ -93,17 +131,15 @@ public class AiEntryParser { .body(reqBody) .retrieve() .body(JsonNode.class); - String out = stripFences(resp.path("content").path(0).path("text").asText("")).trim(); log.info("[ai-parse] 모델 원문='{}'", out); // 모델이 설명문을 섞어 보내도 첫 '{'~마지막 '}' 만 추출해 JSON 파싱(견고성). String json = extractJson(out); if (json == null) { - log.info("[ai-parse] JSON 없음 — 거래 아님으로 간주, 규칙기반 폴백"); + log.info("[ai-parse] JSON 없음 — 미인식으로 간주"); return Optional.empty(); } JsonNode j = om.readTree(json); - String type = "INCOME".equalsIgnoreCase(j.path("type").asText("EXPENSE")) ? "INCOME" : "EXPENSE"; LocalDate date; try { @@ -111,24 +147,21 @@ public class AiEntryParser { } catch (Exception e) { date = today; } - // 후보 목록에 실제 있는 값만 채택(모델이 없는 값을 지어내는 것 방지) - String category = pickFromHints(j.path("category").asText(""), categoryHints); - String wallet = pickFromHints(j.path("wallet").asText(""), walletHints); ParsedEntryResponse result = ParsedEntryResponse.builder() .recognized(j.path("recognized").asBoolean(false)) .type(type) .amount(Math.max(0, j.path("amount").asLong(0))) .merchant(j.path("merchant").asText("")) .date(date) - .category(category) - .wallet(wallet) + .category(pickFromHints(j.path("category").asText(""), categoryHints)) + .wallet(pickFromHints(j.path("wallet").asText(""), walletHints)) .build(); log.info("[ai-parse] 결과 — recognized={}, type={}, amount={}, merchant={}, date={}, category={}, wallet={}", result.isRecognized(), result.getType(), result.getAmount(), result.getMerchant(), result.getDate(), result.getCategory(), result.getWallet()); return Optional.of(result); } catch (Exception e) { - log.warn("[ai-parse] 실패 → 규칙기반 폴백: {}", e.toString()); + log.warn("[ai-parse] 실패 → 폴백: {}", e.toString()); return Optional.empty(); } }