/account/ocr/receipt-ai — 이미지를 Haiku 비전에 보내 금액·상호·날짜·분류·계좌를 한 번에 추출. AI 미설정/실패 시 recognized=false → 프론트가 기존 Google Vision OCR 로 폴백. Claude 호출/응답파싱 공통화(callClaude), 프롬프트 힌트주입 공통화(withHints). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
package com.sb.web.account.controller;
|
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.account.service.VisionOcrService;
|
||||||
import com.sb.web.auth.dto.SessionUser;
|
import com.sb.web.auth.dto.SessionUser;
|
||||||
import com.sb.web.auth.web.AuthInterceptor;
|
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 org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 영수증 OCR API (/api/account/ocr/** — 로그인 필요).
|
* 영수증 OCR API (/api/account/ocr/** — 로그인 필요).
|
||||||
* 이미지 업로드 → Google Vision 으로 텍스트 추출 → 프론트에서 금액·날짜·상호 파싱.
|
* - /receipt : Google Vision 텍스트 추출(프론트 규칙 파서용, 폴백)
|
||||||
|
* - /receipt-ai: Claude Vision 으로 영수증을 구조화(금액·상호·날짜·분류·계좌). AI 미설정/실패 시 recognized=false.
|
||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/account/ocr")
|
@RequestMapping("/api/account/ocr")
|
||||||
@@ -26,6 +31,7 @@ import java.util.Map;
|
|||||||
public class OcrController {
|
public class OcrController {
|
||||||
|
|
||||||
private final VisionOcrService visionOcrService;
|
private final VisionOcrService visionOcrService;
|
||||||
|
private final AiEntryParser aiEntryParser;
|
||||||
|
|
||||||
@PostMapping("/receipt")
|
@PostMapping("/receipt")
|
||||||
public Map<String, String> receipt(
|
public Map<String, String> receipt(
|
||||||
@@ -37,4 +43,19 @@ public class OcrController {
|
|||||||
String text = visionOcrService.recognize(image.getBytes());
|
String text = visionOcrService.recognize(image.getBytes());
|
||||||
return Map.of("text", text);
|
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<String> categoryHints,
|
||||||
|
@RequestParam(value = "walletHints", required = false) List<String> 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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import org.springframework.stereotype.Component;
|
|||||||
import org.springframework.web.client.RestClient;
|
import org.springframework.web.client.RestClient;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.util.Base64;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -57,10 +58,7 @@ public class AiEntryParser {
|
|||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
log.info("[ai-parse] 요청 수신 — text='{}'", text);
|
log.info("[ai-parse] 요청 수신 — text='{}'", text);
|
||||||
try {
|
String system = withHints("""
|
||||||
String catList = (categoryHints == null || categoryHints.isEmpty()) ? "(없음)" : String.join(", ", categoryHints);
|
|
||||||
String walletList = (walletHints == null || walletHints.isEmpty()) ? "(없음)" : String.join(", ", walletHints);
|
|
||||||
String system = ("""
|
|
||||||
너는 한국어 가계부 입력 도우미다. 사용자가 자연어로 쓴 한 줄 지출/수입 메모를 구조화한다.
|
너는 한국어 가계부 입력 도우미다. 사용자가 자연어로 쓴 한 줄 지출/수입 메모를 구조화한다.
|
||||||
반드시 아래 JSON 객체 '하나만' 출력한다(마크다운/설명 금지):
|
반드시 아래 JSON 객체 '하나만' 출력한다(마크다운/설명 금지):
|
||||||
{"recognized":boolean,"type":"INCOME"|"EXPENSE","amount":정수(원),"merchant":"문자열","date":"YYYY-MM-DD","category":"문자열","wallet":"문자열"}
|
{"recognized":boolean,"type":"INCOME"|"EXPENSE","amount":정수(원),"merchant":"문자열","date":"YYYY-MM-DD","category":"문자열","wallet":"문자열"}
|
||||||
@@ -74,17 +72,57 @@ public class AiEntryParser {
|
|||||||
- 금액을 못 찾거나 거래가 아니면 recognized=false.
|
- 금액을 못 찾거나 거래가 아니면 recognized=false.
|
||||||
분류 후보: __CATS__
|
분류 후보: __CATS__
|
||||||
결제수단 후보: __WALLETS__
|
결제수단 후보: __WALLETS__
|
||||||
""")
|
""", today, categoryHints, walletHints);
|
||||||
.replace("__TODAY__", today.toString())
|
return callClaude(system, text, 300, today, categoryHints, walletHints);
|
||||||
.replace("__CATS__", catList)
|
}
|
||||||
.replace("__WALLETS__", walletList);
|
|
||||||
|
|
||||||
|
/** 영수증 이미지 → 구조화(Claude Vision). 실패/미설정 시 empty → 호출부가 기존 OCR 로 폴백. */
|
||||||
|
public Optional<ParsedEntryResponse> parseReceipt(byte[] image, String mediaType, LocalDate today,
|
||||||
|
List<String> categoryHints, List<String> 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<Object> 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<String> categoryHints, List<String> 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<ParsedEntryResponse> callClaude(String system, Object userContent, int maxTokens,
|
||||||
|
LocalDate today, List<String> categoryHints, List<String> walletHints) {
|
||||||
|
try {
|
||||||
Map<String, Object> reqBody = Map.of(
|
Map<String, Object> reqBody = Map.of(
|
||||||
"model", model,
|
"model", model,
|
||||||
"max_tokens", 300,
|
"max_tokens", maxTokens,
|
||||||
"system", system,
|
"system", system,
|
||||||
"messages", List.of(Map.of("role", "user", "content", text)));
|
"messages", List.of(Map.of("role", "user", "content", userContent)));
|
||||||
|
|
||||||
JsonNode resp = rest.post()
|
JsonNode resp = rest.post()
|
||||||
.uri("/v1/messages")
|
.uri("/v1/messages")
|
||||||
.header("x-api-key", apiKey)
|
.header("x-api-key", apiKey)
|
||||||
@@ -93,17 +131,15 @@ public class AiEntryParser {
|
|||||||
.body(reqBody)
|
.body(reqBody)
|
||||||
.retrieve()
|
.retrieve()
|
||||||
.body(JsonNode.class);
|
.body(JsonNode.class);
|
||||||
|
|
||||||
String out = stripFences(resp.path("content").path(0).path("text").asText("")).trim();
|
String out = stripFences(resp.path("content").path(0).path("text").asText("")).trim();
|
||||||
log.info("[ai-parse] 모델 원문='{}'", out);
|
log.info("[ai-parse] 모델 원문='{}'", out);
|
||||||
// 모델이 설명문을 섞어 보내도 첫 '{'~마지막 '}' 만 추출해 JSON 파싱(견고성).
|
// 모델이 설명문을 섞어 보내도 첫 '{'~마지막 '}' 만 추출해 JSON 파싱(견고성).
|
||||||
String json = extractJson(out);
|
String json = extractJson(out);
|
||||||
if (json == null) {
|
if (json == null) {
|
||||||
log.info("[ai-parse] JSON 없음 — 거래 아님으로 간주, 규칙기반 폴백");
|
log.info("[ai-parse] JSON 없음 — 미인식으로 간주");
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
JsonNode j = om.readTree(json);
|
JsonNode j = om.readTree(json);
|
||||||
|
|
||||||
String type = "INCOME".equalsIgnoreCase(j.path("type").asText("EXPENSE")) ? "INCOME" : "EXPENSE";
|
String type = "INCOME".equalsIgnoreCase(j.path("type").asText("EXPENSE")) ? "INCOME" : "EXPENSE";
|
||||||
LocalDate date;
|
LocalDate date;
|
||||||
try {
|
try {
|
||||||
@@ -111,24 +147,21 @@ public class AiEntryParser {
|
|||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
date = today;
|
date = today;
|
||||||
}
|
}
|
||||||
// 후보 목록에 실제 있는 값만 채택(모델이 없는 값을 지어내는 것 방지)
|
|
||||||
String category = pickFromHints(j.path("category").asText(""), categoryHints);
|
|
||||||
String wallet = pickFromHints(j.path("wallet").asText(""), walletHints);
|
|
||||||
ParsedEntryResponse result = ParsedEntryResponse.builder()
|
ParsedEntryResponse result = ParsedEntryResponse.builder()
|
||||||
.recognized(j.path("recognized").asBoolean(false))
|
.recognized(j.path("recognized").asBoolean(false))
|
||||||
.type(type)
|
.type(type)
|
||||||
.amount(Math.max(0, j.path("amount").asLong(0)))
|
.amount(Math.max(0, j.path("amount").asLong(0)))
|
||||||
.merchant(j.path("merchant").asText(""))
|
.merchant(j.path("merchant").asText(""))
|
||||||
.date(date)
|
.date(date)
|
||||||
.category(category)
|
.category(pickFromHints(j.path("category").asText(""), categoryHints))
|
||||||
.wallet(wallet)
|
.wallet(pickFromHints(j.path("wallet").asText(""), walletHints))
|
||||||
.build();
|
.build();
|
||||||
log.info("[ai-parse] 결과 — recognized={}, type={}, amount={}, merchant={}, date={}, category={}, wallet={}",
|
log.info("[ai-parse] 결과 — recognized={}, type={}, amount={}, merchant={}, date={}, category={}, wallet={}",
|
||||||
result.isRecognized(), result.getType(), result.getAmount(), result.getMerchant(), result.getDate(),
|
result.isRecognized(), result.getType(), result.getAmount(), result.getMerchant(), result.getDate(),
|
||||||
result.getCategory(), result.getWallet());
|
result.getCategory(), result.getWallet());
|
||||||
return Optional.of(result);
|
return Optional.of(result);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("[ai-parse] 실패 → 규칙기반 폴백: {}", e.toString());
|
log.warn("[ai-parse] 실패 → 폴백: {}", e.toString());
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user