From fcc45606236ef70c0d64d0b64cd1e93e5350f470 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Sun, 6 Sep 2026 21:15:27 +0900 Subject: [PATCH 01/11] =?UTF-8?q?feat:=20LLM(gpt-4o)=20=EC=95=BD=20?= =?UTF-8?q?=EC=B6=94=EC=B6=9C=EA=B8=B0=20=EC=B6=94=EA=B0=80=20=E2=80=94=20?= =?UTF-8?q?OpenAiClient,=20DrugExtractor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OCR 필드(텍스트+좌표)를 "텍스트 @(x,y)" 목록으로 만들어 gpt-4o Chat Completions에 넘기고 약 목록 JSON을 받는다. - DrugExtractor: List extract(fields). 실패 시 예외 대신 빈 리스트(폴백 신호) - LlmDrugExtractor: 키 미설정·API 오류 → 빈 리스트. sanity check(횟수 1~6, 일수 1~90 벗어나면 null) - OpenAiClient: temperature 0, response_format json_object, 25s 타임아웃. 프롬프트 — 제형 접미사 유지, 용량 표기·성분명·제형어 제외, "교부일로부터 N일" 제외 Co-Authored-By: Claude Sonnet 5 --- .../domain/ocrresult/client/OpenAiClient.java | 127 ++++++++++++++++++ .../ocrresult/service/DrugExtractor.java | 17 +++ .../ocrresult/service/LlmDrugExtractor.java | 66 +++++++++ 3 files changed, 210 insertions(+) create mode 100644 src/main/java/com/piuda/callcare/domain/ocrresult/client/OpenAiClient.java create mode 100644 src/main/java/com/piuda/callcare/domain/ocrresult/service/DrugExtractor.java create mode 100644 src/main/java/com/piuda/callcare/domain/ocrresult/service/LlmDrugExtractor.java diff --git a/src/main/java/com/piuda/callcare/domain/ocrresult/client/OpenAiClient.java b/src/main/java/com/piuda/callcare/domain/ocrresult/client/OpenAiClient.java new file mode 100644 index 0000000..c44cfcf --- /dev/null +++ b/src/main/java/com/piuda/callcare/domain/ocrresult/client/OpenAiClient.java @@ -0,0 +1,127 @@ +package com.piuda.callcare.domain.ocrresult.client; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.piuda.callcare.domain.ocrresult.dto.response.NaverOcrApiResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +import java.time.Duration; +import java.util.List; +import java.util.Map; + +/** + * OpenAI Chat Completions 호출 래퍼. OCR 필드(텍스트+좌표)를 주면 약 목록 JSON을 받아온다. + *

+ * {@code openai.api-key}가 비어 있으면 비활성({@link #isEnabled()} false) — 상위에서 파서로 폴백. + */ +@Slf4j +@Component +public class OpenAiClient { + + private static final String SYSTEM_PROMPT = """ + 너는 한국 병원 처방전·약국 약봉투의 OCR 결과에서 복용할 약 목록을 뽑는 도우미다. + 입력은 "텍스트 @(x,y)" 줄 목록이다. x=왼쪽 기준, y=위쪽 기준 픽셀 좌표. + y 차이가 15 이내면 같은 행이다. + + [행 해석] + 한 약의 정보는 대개 한 행에 있다. 약 이름 오른쪽, 같은 행에 있는 한 자리 숫자들을 + x가 작은 순서대로 [1회 투약량, 1일 투여횟수, 총 투약일수]에 대응시켜라. + "1정씩2회5일분" 같은 압축 표기가 있으면 그대로 파싱해라(1정 / 2회 / 5일). + + [drugName] + - 제형 접미사(정, 캡슐, 캅셀, 시럽, 서방정, 장용정, 액 등)는 이름의 일부다. 절대 떼지 마라. + - 용량 표기(500mg, 5/50밀리그램)와 괄호 속 성분명은 빼도 된다. + - "코팅정", "필름코팅정", "경질캡슐" 처럼 제형만 나타내는 단어는 약 이름이 아니다. + - 성분명만 있는 줄(예: "메트포르민염산염 1000mg", "아세트아미노펜")은 약이 아니다. 제품명만 뽑아라. + - OCR 오타가 의심돼도 확신 없으면 원문 그대로 둬라(임의로 다른 약명으로 바꾸지 마라). + + [dosagePerTime] 1회 투약량. 반드시 단위 포함 문자열: "1정", "0.5정", "1캡슐", "1포", "5ml". + 숫자만 보이면 약 형태로 단위 추정. 모르면 null. + [timesPerDay] 1일 투여 횟수 정수(보통 1~4). 모르면 null. + [totalDays] 총 투약 일수 정수. "교부일로부터 N일", "복약만료일" 같은 건 총 투약일수가 아니다. 모르면 null. + + [제외] 주의사항, 병원·약국명, 의사·약사 이름, 금액, 날짜, 보험코드(8~10자리 숫자), 환자 정보. + + 아래 JSON 형식으로만 응답한다: + {"drugs": [ { "drugName": "...", "dosagePerTime": "...", "timesPerDay": 0, "totalDays": 0 } ]} + """; + + private final WebClient webClient; + private final ObjectMapper objectMapper; + + @Value("${openai.api-key:}") + private String apiKey; + + @Value("${openai.base-url:https://api.openai.com/v1}") + private String baseUrl; + + @Value("${openai.model:gpt-4o-mini}") + private String model; + + public OpenAiClient(WebClient webClient, ObjectMapper objectMapper) { + this.webClient = webClient; + this.objectMapper = objectMapper; + } + + public boolean isEnabled() { + return StringUtils.hasText(apiKey); + } + + /** OCR 필드 → 약 목록 JSON의 "drugs" 배열. 실패 시 예외 전파(상위에서 처리). */ + public JsonNode extractDrugs(List fields) throws Exception { + String userContent = toCoordinateText(fields); + + Map body = Map.of( + "model", model, + "temperature", 0, + "response_format", Map.of("type", "json_object"), + "messages", List.of( + Map.of("role", "system", "content", SYSTEM_PROMPT), + Map.of("role", "user", "content", userContent) + ) + ); + + String response; + try { + response = webClient.post() + .uri(baseUrl + "/chat/completions") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + apiKey) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(body) + .retrieve() + .bodyToMono(String.class) + .block(Duration.ofSeconds(25)); + } catch (WebClientResponseException e) { + log.error("OpenAI 오류 {} - {}", e.getStatusCode(), e.getResponseBodyAsString()); + throw e; + } + + JsonNode root = objectMapper.readTree(response); + String content = root.path("choices").path(0).path("message").path("content").asText(""); + return objectMapper.readTree(content).path("drugs"); + } + + // 각 필드를 "텍스트 @(중심x,중심y)" 한 줄로. y→x 순 정렬해 표 구조를 읽기 쉽게. + private String toCoordinateText(List fields) { + record Line(String text, int x, int y) {} + return fields.stream() + .filter(f -> f.inferText() != null && !f.inferText().isBlank()) + .map(f -> { + List v = f.boundingPoly() != null ? f.boundingPoly().vertices() : List.of(); + int cx = v.isEmpty() ? 0 : (int) v.stream().mapToDouble(NaverOcrApiResponse.Vertex::x).average().orElse(0); + int cy = v.isEmpty() ? 0 : (int) v.stream().mapToDouble(NaverOcrApiResponse.Vertex::y).average().orElse(0); + return new Line(f.inferText().trim(), cx, cy); + }) + .sorted((a, b) -> a.y() != b.y() ? Integer.compare(a.y(), b.y()) : Integer.compare(a.x(), b.x())) + .map(l -> l.text() + " @(" + l.x() + "," + l.y() + ")") + .reduce((a, b) -> a + "\n" + b) + .orElse(""); + } +} diff --git a/src/main/java/com/piuda/callcare/domain/ocrresult/service/DrugExtractor.java b/src/main/java/com/piuda/callcare/domain/ocrresult/service/DrugExtractor.java new file mode 100644 index 0000000..d1c23f9 --- /dev/null +++ b/src/main/java/com/piuda/callcare/domain/ocrresult/service/DrugExtractor.java @@ -0,0 +1,17 @@ +package com.piuda.callcare.domain.ocrresult.service; + +import com.piuda.callcare.domain.ocrresult.dto.ParsedOcrData; +import com.piuda.callcare.domain.ocrresult.dto.response.NaverOcrApiResponse; + +import java.util.List; + +/** + * OCR 인식 결과({@code fields} — 텍스트 + 좌표)에서 약 목록을 추출한다. + *

+ * 구현체는 실패(외부 API 오류 등) 시 예외를 던지지 않고 빈 리스트를 반환한다. + * 호출 측은 빈 리스트를 "이 추출기로는 못 뽑음 → 폴백" 신호로 쓴다. + */ +public interface DrugExtractor { + + List extract(List fields); +} diff --git a/src/main/java/com/piuda/callcare/domain/ocrresult/service/LlmDrugExtractor.java b/src/main/java/com/piuda/callcare/domain/ocrresult/service/LlmDrugExtractor.java new file mode 100644 index 0000000..7e0a426 --- /dev/null +++ b/src/main/java/com/piuda/callcare/domain/ocrresult/service/LlmDrugExtractor.java @@ -0,0 +1,66 @@ +package com.piuda.callcare.domain.ocrresult.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.piuda.callcare.domain.ocrresult.client.OpenAiClient; +import com.piuda.callcare.domain.ocrresult.dto.ParsedOcrData; +import com.piuda.callcare.domain.ocrresult.dto.response.NaverOcrApiResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +/** + * LLM(OpenAI) 기반 약 추출기. 키 미설정·API 오류·빈 응답이면 빈 리스트를 반환해 파서 폴백을 유도한다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class LlmDrugExtractor implements DrugExtractor { + + private static final int MAX_TIMES_PER_DAY = 6; + private static final int MAX_TOTAL_DAYS = 90; + + private final OpenAiClient openAiClient; + + @Override + public List extract(List fields) { + if (!openAiClient.isEnabled() || fields == null || fields.isEmpty()) { + return List.of(); + } + try { + JsonNode drugs = openAiClient.extractDrugs(fields); + List result = new ArrayList<>(); + for (JsonNode d : drugs) { + String name = text(d, "drugName"); + if (name == null) continue; + result.add(new ParsedOcrData( + name, + text(d, "dosagePerTime"), + boundedInt(d, "timesPerDay", MAX_TIMES_PER_DAY), + boundedInt(d, "totalDays", MAX_TOTAL_DAYS) + )); + } + return result; + } catch (Exception e) { + log.warn("LLM 약 추출 실패 - 파서로 폴백", e); + return List.of(); + } + } + + private String text(JsonNode node, String field) { + JsonNode v = node.get(field); + if (v == null || v.isNull()) return null; + String s = v.asText().trim(); + return s.isEmpty() ? null : s; + } + + // 범위를 벗어나는 값은 환각으로 보고 null 처리 + private Integer boundedInt(JsonNode node, String field, int max) { + JsonNode v = node.get(field); + if (v == null || v.isNull() || !v.canConvertToInt()) return null; + int n = v.asInt(); + return (n >= 1 && n <= max) ? n : null; + } +} From 1633c22a607b9c24cf08dcaf16e048424fca7761 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Sun, 6 Sep 2026 21:15:27 +0900 Subject: [PATCH 02/11] =?UTF-8?q?feat:=20OcrParser=EC=97=90=20=ED=95=98?= =?UTF-8?q?=EC=9D=B4=EB=B8=8C=EB=A6=AC=EB=93=9C=20=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=ED=8C=85=20=EC=8B=A0=ED=98=B8=20hasCodedPrescriptionLines=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 보험코드 줄(8~10자리 + 약이름)이 있으면 병원 처방전. 이 서식은 저화질이어도 좌표 알고리즘(파서)이 숫자 컬럼을 정확히 잡는 반면 LLM은 좌표 텍스트로 표를 못 읽는다(실측). 상위에서 "파서로 보낼 것" 신호로 쓴다. Co-Authored-By: Claude Sonnet 5 --- .../callcare/domain/ocrresult/service/OcrParser.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/main/java/com/piuda/callcare/domain/ocrresult/service/OcrParser.java b/src/main/java/com/piuda/callcare/domain/ocrresult/service/OcrParser.java index 5c282d8..08d704e 100644 --- a/src/main/java/com/piuda/callcare/domain/ocrresult/service/OcrParser.java +++ b/src/main/java/com/piuda/callcare/domain/ocrresult/service/OcrParser.java @@ -88,6 +88,17 @@ public class OcrParser { private static final List TIMES_KEYWORDS = List.of("투여횟수", "복용횟수", "횟수"); private static final List DAYS_KEYWORDS = List.of("투약일수", "복용일수", "일수"); + /** + * 보험코드 줄이 있는 병원 처방전이면 true. + *

+ * 이 서식은 저화질이라 헤더가 뭉개져도 좌표 알고리즘(파서)이 숫자 컬럼을 정확히 잡는 반면 + * LLM은 좌표 텍스트로 표를 못 읽는다(실측). 하이브리드 라우팅에서 "파서로 보낼 것" 신호. + * 약봉투·영수증은 여기 걸리지 않으므로 LLM 경로로 간다. + */ + public boolean hasCodedPrescriptionLines(List fields) { + return hasPrescriptionCodeLines(buildRawText(fields)); + } + public OcrParseResult parse(List fields, OcrType ocrType) { String rawText = buildRawText(fields); From 2958cb6418a1de9a93e6a79f5eea22ea823744a6 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Sun, 6 Sep 2026 21:15:27 +0900 Subject: [PATCH 03/11] =?UTF-8?q?feat:=20OcrCommandService=20=ED=95=98?= =?UTF-8?q?=EC=9D=B4=EB=B8=8C=EB=A6=AC=EB=93=9C=20=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=ED=8C=85=20=E2=80=94=20=EC=B2=98=EB=B0=A9=EC=A0=84=3D=ED=8C=8C?= =?UTF-8?q?=EC=84=9C,=20=EA=B7=B8=20=EC=99=B8=3DLLM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 보험코드 줄 처방전이면 파서(좌표) 결과, 아니면 gpt-4o 추출, LLM 실패·약 0개면 파서 폴백. 응답 스키마 불변. method(파서(처방전)/LLM/파서(폴백))를 로그에 남긴다. Co-Authored-By: Claude Sonnet 5 --- .../service/command/OcrCommandService.java | 36 +++++++++++++++---- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/piuda/callcare/domain/ocrresult/service/command/OcrCommandService.java b/src/main/java/com/piuda/callcare/domain/ocrresult/service/command/OcrCommandService.java index 5cf1b9f..ca3d74d 100644 --- a/src/main/java/com/piuda/callcare/domain/ocrresult/service/command/OcrCommandService.java +++ b/src/main/java/com/piuda/callcare/domain/ocrresult/service/command/OcrCommandService.java @@ -9,6 +9,7 @@ import com.piuda.callcare.domain.ocrresult.entity.OcrResult; import com.piuda.callcare.domain.ocrresult.enums.OcrType; import com.piuda.callcare.domain.ocrresult.repository.OcrResultRepository; +import com.piuda.callcare.domain.ocrresult.service.DrugExtractor; import com.piuda.callcare.domain.ocrresult.service.OcrParser; import com.piuda.callcare.domain.senior.entity.Senior; import com.piuda.callcare.domain.senior.repository.SeniorRepository; @@ -20,6 +21,8 @@ import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; +import java.util.List; + @Slf4j @Service @RequiredArgsConstructor @@ -27,6 +30,7 @@ public class OcrCommandService { private final NaverOcrClient naverOcrClient; private final OcrParser ocrParser; + private final DrugExtractor drugExtractor; private final OcrResultRepository ocrResultRepository; private final OcrResultConverter ocrResultConverter; private final SeniorRepository seniorRepository; @@ -38,10 +42,28 @@ public OcrResultResponse processOcr(Long userId, Long seniorId, MultipartFile im // OCR 호출 → fields(텍스트 + 좌표 블록 목록) + 응답 원문 반환 NaverOcrCallResult ocrCallResult = naverOcrClient.callOcr(image); - // 파싱: rawText 조립 + 약 정보 추출 (표 처방전이면 여러 약) + // rawText 조립 + 처방일 + 파서 약 추출 OcrParseResult parseResult = ocrParser.parse(ocrCallResult.fields(), ocrType); - // OcrResult DB 저장: rawText + 응답 원문 + 첫 번째 약 파싱 결과. 주민번호는 저장 전 마스킹 + // 하이브리드 라우팅 (실측 기반): + // - 보험코드 줄 처방전: 저화질에서 파서(좌표)가 LLM보다 정확 → 파서 결과 사용 + // - 약봉투/영수증/그 외: LLM 추출, 실패 시 파서 폴백 + List parsedDrugs; + String method; + if (ocrParser.hasCodedPrescriptionLines(ocrCallResult.fields()) && !parseResult.parsedDrugs().isEmpty()) { + parsedDrugs = parseResult.parsedDrugs(); + method = "파서(처방전)"; + } else { + parsedDrugs = drugExtractor.extract(ocrCallResult.fields()); + if (!parsedDrugs.isEmpty()) { + method = "LLM"; + } else { + parsedDrugs = parseResult.parsedDrugs(); + method = "파서(폴백)"; + } + } + + // OcrResult DB 저장: rawText + 응답 원문 + 첫 번째 약. 주민번호는 저장 전 마스킹 OcrResult ocrResult = OcrResult.builder() .senior(senior) .ocrType(ocrType) @@ -49,17 +71,17 @@ public OcrResultResponse processOcr(Long userId, Long seniorId, MultipartFile im .rawResponse(PiiMasker.maskResidentNumber(ocrCallResult.rawResponseJson())) .build(); - ParsedOcrData first = parseResult.parsedDrugs().isEmpty() + ParsedOcrData first = parsedDrugs.isEmpty() ? new ParsedOcrData(null, null, null, null) - : parseResult.parsedDrugs().get(0); + : parsedDrugs.get(0); ocrResult.saveParsedData(first.drugName(), first.dosagePerTime(), first.timesPerDay(), first.totalDays()); ocrResult.markAsProcessed(); OcrResult saved = ocrResultRepository.save(ocrResult); - log.info("OCR 처리 완료 - ocrResultId: {}, ocrType: {}, 파싱된 약 수: {}", - saved.getId(), ocrType, parseResult.parsedDrugs().size()); + log.info("OCR 처리 완료 - ocrResultId: {}, ocrType: {}, 추출: {}, 약 수: {}", + saved.getId(), ocrType, method, parsedDrugs.size()); - return ocrResultConverter.toResponse(saved, parseResult.parsedDrugs(), parseResult.prescriptionDate()); + return ocrResultConverter.toResponse(saved, parsedDrugs, parseResult.prescriptionDate()); } } From 2ff6315edaf8d4e145b27f8079d158f97702201e Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Sun, 6 Sep 2026 21:15:27 +0900 Subject: [PATCH 04/11] =?UTF-8?q?chore:=20openai=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(OPENAI=5FAPI=5FKEY=20=EC=97=86=EC=9C=BC?= =?UTF-8?q?=EB=A9=B4=20LLM=20=EB=B9=84=ED=99=9C=EC=84=B1=20=E2=86=92=20?= =?UTF-8?q?=ED=8C=8C=EC=84=9C=20=EC=A0=84=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- src/main/resources/application.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ddf6dee..faec877 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -52,6 +52,12 @@ naver: invoke-url: ${NAVER_OCR_INVOKE_URL} secret-key: ${NAVER_OCR_SECRET_KEY} +openai: + api-key: ${OPENAI_API_KEY:} + base-url: ${OPENAI_BASE_URL:https://api.openai.com/v1} + # gpt-4o: 실측상 gpt-4o-mini는 제형 접미사·괄호를 제대로 못 떼고 약을 누락함 + model: ${OPENAI_MODEL:gpt-4o} + coolsms: api-key: ${COOLSMS_API_KEY:} api-secret: ${COOLSMS_API_SECRET:} From 65bb3babf923434a5b0fef217a512eb2ed14f737 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Sun, 6 Sep 2026 21:15:27 +0900 Subject: [PATCH 05/11] =?UTF-8?q?test:=20=ED=95=98=EC=9D=B4=EB=B8=8C?= =?UTF-8?q?=EB=A6=AC=EB=93=9C=20=EB=9D=BC=EC=9A=B0=ED=8C=85=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20+=20=ED=8C=8C?= =?UTF-8?q?=EC=84=9C/LLM=20=EB=B9=84=EA=B5=90=20=ED=95=98=EB=84=A4?= =?UTF-8?q?=EC=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OcrCommandServiceTest: 처방전→파서(LLM 미호출), 약봉투→LLM, LLM 실패→파서 폴백 - LlmDrugExtractorComparisonTest: fixture 9종 파서 vs LLM vs 하이브리드 정확도. @Tag("integration") + OPENAI_API_KEY 있을 때만 실행 Co-Authored-By: Claude Sonnet 5 --- .../LlmDrugExtractorComparisonTest.java | 69 +++++++++++ .../command/OcrCommandServiceTest.java | 114 ++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 src/test/java/com/piuda/callcare/domain/ocrresult/service/LlmDrugExtractorComparisonTest.java create mode 100644 src/test/java/com/piuda/callcare/domain/ocrresult/service/command/OcrCommandServiceTest.java diff --git a/src/test/java/com/piuda/callcare/domain/ocrresult/service/LlmDrugExtractorComparisonTest.java b/src/test/java/com/piuda/callcare/domain/ocrresult/service/LlmDrugExtractorComparisonTest.java new file mode 100644 index 0000000..71cba7e --- /dev/null +++ b/src/test/java/com/piuda/callcare/domain/ocrresult/service/LlmDrugExtractorComparisonTest.java @@ -0,0 +1,69 @@ +package com.piuda.callcare.domain.ocrresult.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.piuda.callcare.domain.ocrresult.client.OpenAiClient; +import com.piuda.callcare.domain.ocrresult.dto.ParsedOcrData; +import com.piuda.callcare.domain.ocrresult.dto.response.NaverOcrApiResponse; +import com.piuda.callcare.domain.ocrresult.enums.OcrType; +import com.piuda.callcare.domain.ocrresult.fixture.OcrFixtureLoader; +import com.piuda.callcare.domain.ocrresult.fixture.OcrFixtureLoader.FixtureCase; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.TestFactory; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.List; +import java.util.stream.Stream; + +/** + * 실제 OpenAI를 호출해 fixture 9종에서 파서 vs LLM 추출 정확도를 비교한다(리포트). + * OPENAI_API_KEY 환경변수가 있을 때만 실행. 호출 비용이 발생하므로 @Tag("integration"). + */ +@Tag("integration") +@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+") +@DisplayName("파서 vs LLM 추출 비교") +class LlmDrugExtractorComparisonTest { + + private final OcrParser parser = new OcrParser(); + private final LlmDrugExtractor llm = buildLlmExtractor(); + + @TestFactory + Stream 비교_리포트() { + return OcrFixtureLoader.loadManifest().fixtures().stream() + .filter(fc -> OcrFixtureLoader.exists(fc.file())) + .map(fc -> DynamicTest.dynamicTest(fc.file(), () -> { + List fields = OcrFixtureLoader.loadFields(fc.file()); + OcrType type = OcrType.valueOf(fc.ocrType()); + + List byParser = parser.parse(fields, type).parsedDrugs(); + List byLlm = llm.extract(fields); + + boolean useParser = parser.hasCodedPrescriptionLines(fields) && !byParser.isEmpty(); + List hybrid = useParser ? byParser + : (byLlm.isEmpty() ? byParser : byLlm); + String route = useParser ? "파서(처방전)" : (byLlm.isEmpty() ? "파서(폴백)" : "LLM"); + + System.out.printf("[%s] 기대 %d | 파서 %.2f | LLM %.2f | 하이브리드 %.2f (%s)%n", + fc.file(), fc.expected().size(), + recall(fc.expected(), byParser), recall(fc.expected(), byLlm), + recall(fc.expected(), hybrid), route); + })); + } + + private double recall(List expected, List actual) { + if (expected.isEmpty()) return 0; + long hit = expected.stream().filter(actual::contains).count(); + return (double) hit / expected.size(); + } + + private static LlmDrugExtractor buildLlmExtractor() { + OpenAiClient client = new OpenAiClient(WebClient.create(), new ObjectMapper()); + ReflectionTestUtils.setField(client, "apiKey", System.getenv("OPENAI_API_KEY")); + ReflectionTestUtils.setField(client, "baseUrl", "https://api.openai.com/v1"); + ReflectionTestUtils.setField(client, "model", System.getenv().getOrDefault("OPENAI_MODEL", "gpt-4o")); + return new LlmDrugExtractor(client); + } +} diff --git a/src/test/java/com/piuda/callcare/domain/ocrresult/service/command/OcrCommandServiceTest.java b/src/test/java/com/piuda/callcare/domain/ocrresult/service/command/OcrCommandServiceTest.java new file mode 100644 index 0000000..b461ef8 --- /dev/null +++ b/src/test/java/com/piuda/callcare/domain/ocrresult/service/command/OcrCommandServiceTest.java @@ -0,0 +1,114 @@ +package com.piuda.callcare.domain.ocrresult.service.command; + +import com.piuda.callcare.domain.ocrresult.client.NaverOcrClient; +import com.piuda.callcare.domain.ocrresult.converter.OcrResultConverter; +import com.piuda.callcare.domain.ocrresult.dto.NaverOcrCallResult; +import com.piuda.callcare.domain.ocrresult.dto.OcrParseResult; +import com.piuda.callcare.domain.ocrresult.dto.ParsedOcrData; +import com.piuda.callcare.domain.ocrresult.entity.OcrResult; +import com.piuda.callcare.domain.ocrresult.enums.OcrType; +import com.piuda.callcare.domain.ocrresult.repository.OcrResultRepository; +import com.piuda.callcare.domain.ocrresult.service.DrugExtractor; +import com.piuda.callcare.domain.ocrresult.service.OcrParser; +import com.piuda.callcare.domain.senior.entity.Senior; +import com.piuda.callcare.domain.senior.repository.SeniorRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +@ExtendWith(MockitoExtension.class) +@DisplayName("OcrCommandService 단위 테스트 — 하이브리드 라우팅 (처방전=파서 / 그 외=LLM)") +class OcrCommandServiceTest { + + @InjectMocks private OcrCommandService ocrCommandService; + + @Mock private NaverOcrClient naverOcrClient; + @Mock private OcrParser ocrParser; + @Mock private DrugExtractor drugExtractor; + @Mock private OcrResultRepository ocrResultRepository; + @Mock private OcrResultConverter ocrResultConverter; + @Mock private SeniorRepository seniorRepository; + + private static final ParsedOcrData PARSER_DRUG = new ParsedOcrData("파서약", "1정", 3, 3); + private static final ParsedOcrData LLM_DRUG = new ParsedOcrData("LLM약", "2정", 2, 5); + + private void commonStubs() { + given(seniorRepository.findByIdAndUser_Id(anyLong(), anyLong())) + .willReturn(Optional.of(Senior.builder().build())); + given(naverOcrClient.callOcr(any())) + .willReturn(new NaverOcrCallResult(List.of(), "{}")); + given(ocrParser.parse(any(), any())) + .willReturn(new OcrParseResult("raw", List.of(PARSER_DRUG), null)); + given(ocrResultRepository.save(any())).willAnswer(i -> i.getArgument(0)); + // ocrResultConverter.toResponse는 mock 기본값(null) 그대로 사용 + } + + @Test + @DisplayName("보험코드 줄 처방전이면 LLM을 부르지 않고 파서 결과를 쓴다") + void 처방전은_파서_사용() { + // Given + commonStubs(); + given(ocrParser.hasCodedPrescriptionLines(any())).willReturn(true); + + // When + ocrCommandService.processOcr(1L, 1L, image(), OcrType.PRESCRIPTION); + + // Then + assertThat(savedFirstDrugName()).isEqualTo("파서약"); + then(drugExtractor).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("처방전이 아니면(약봉투 등) LLM 결과를 쓴다") + void 약봉투는_LLM_사용() { + // Given + commonStubs(); + given(ocrParser.hasCodedPrescriptionLines(any())).willReturn(false); + given(drugExtractor.extract(any())).willReturn(List.of(LLM_DRUG)); + + // When + ocrCommandService.processOcr(1L, 1L, image(), OcrType.DRUG_BAG); + + // Then + assertThat(savedFirstDrugName()).isEqualTo("LLM약"); + } + + @Test + @DisplayName("처방전이 아니고 LLM이 빈 리스트면 파서 결과로 폴백한다") + void LLM_실패시_파서_폴백() { + // Given + commonStubs(); + given(ocrParser.hasCodedPrescriptionLines(any())).willReturn(false); + given(drugExtractor.extract(any())).willReturn(List.of()); + + // When + ocrCommandService.processOcr(1L, 1L, image(), OcrType.DRUG_BAG); + + // Then + assertThat(savedFirstDrugName()).isEqualTo("파서약"); + } + + private String savedFirstDrugName() { + ArgumentCaptor captor = ArgumentCaptor.forClass(OcrResult.class); + org.mockito.Mockito.verify(ocrResultRepository).save(captor.capture()); + return captor.getValue().getParsedDrugName(); + } + + private MockMultipartFile image() { + return new MockMultipartFile("image", "p.jpg", "image/jpeg", new byte[]{1}); + } +} From a49ed959119103f3db1aacef7994cdd388868c50 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Sun, 6 Sep 2026 21:15:27 +0900 Subject: [PATCH 06/11] =?UTF-8?q?docs:=20=EC=9D=B4=EC=8A=88=20100=20OCR=20?= =?UTF-8?q?LLM=20=ED=95=98=EC=9D=B4=EB=B8=8C=EB=A6=AC=EB=93=9C=20=E2=80=94?= =?UTF-8?q?=20=EB=AC=B8=EC=A0=9C=C2=B7=EC=B8=A1=EC=A0=95=C2=B7=EA=B2=B0?= =?UTF-8?q?=EC=A0=95=C2=B7=EA=B5=AC=ED=98=84=C2=B7=ED=95=9C=EA=B3=84=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- docs/kangcheolung/issue-100-ocr-llm-hybrid.md | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 docs/kangcheolung/issue-100-ocr-llm-hybrid.md diff --git a/docs/kangcheolung/issue-100-ocr-llm-hybrid.md b/docs/kangcheolung/issue-100-ocr-llm-hybrid.md new file mode 100644 index 0000000..412b809 --- /dev/null +++ b/docs/kangcheolung/issue-100-ocr-llm-hybrid.md @@ -0,0 +1,162 @@ +# 이슈 #100 — OCR 약 추출: 정규식/좌표 파서 → LLM 하이브리드 + +> 브랜치: `feature/100` · 관련: [PIUDAProject/Backend#100](https://github.com/PIUDAProject/Backend/issues/100) +> +> 선행: #96(응답 저장·회귀 하네스), #99(파서 고도화). fixture 9종이 "정답"을 정의. + +--- + +## 1. 문제 상황 + +OCR(Naver CLOVA)은 글자를 잘 읽는다. 문제는 **그 글자를 `{약이름, 1회량, 1일횟수, 총일수}` 구조로 바꾸는 파싱**이 서식마다 깨진다는 것. + +### 파서(정규식 + 좌표)의 구조적 한계 + +`OcrParser`는 서식별 분기의 사다리다: + +``` +별표 약봉투(*약이름) → parseByPharmacyReceipt +라벨/압축 약봉투(1정씩2회5일분) → parseByTextSequential +보험코드 처방전(9자리+이름) → parseByPrescriptionCode (#99에서 추가) +표 처방전(명칭/투약량 헤더) → parseByCoordinates +그 외 → parseByRegex (단일 약) +``` + +#99까지 고쳐서 **알려진 서식 8종은 다 통과**하지만: + +- 새 약국 POS 서식 하나가 나오면 → `if` 분기 추가 (밑 빠진 독) +- **뭉개진 약 이름을 교정 못 함** — `지스로먹스장`(OCR 오타)을 `지스로맥스정`으로 못 바꿈. 정규식은 OCR이 준 글자를 그대로 쓸 뿐 +- 영수증처럼 상세 섹션 + 요약표가 섞인 서식에서 취약 + +### 목표 + +파싱 단계를 **LLM 추출**로 대체할 수 있는지 검증하고, 검증 결과에 따라 도입 방식을 정한다. +응답 스키마(`OcrResultResponse.parsedDrugs`)는 유지 → 프론트 영향 0. + +--- + +## 2. 측정 — 파서 vs LLM + +fixture 9종(실제 사진 5 + 합성 4)의 `rawText`/`fields`를 각 방식에 돌려 **약 단위 정확도**를 비교. +정답은 사람이 라벨링(`manifest.json`), 측정 코드는 `LlmDrugExtractorComparisonTest`(키 있을 때만 실행). + +### 2-1. gpt-4o-mini + rawText (글자만) + +| 서식 | 파서 | LLM | +|---|---|---| +| 표 처방전 | 4/4 | **숫자 뒤죽박죽** — `교부일로부터 7일`을 총투약일수 7로 오인 | +| 약봉투 | 4/4 · 9/9 | 개수 맞음, 이름·용량 형식 다름 | + +→ rawText만 주면 표에서 숫자 위치 정보가 없어 컬럼 매핑 실패. + +### 2-2. gpt-4o-mini + 좌표 (`텍스트 @(x,y)` 목록) + +| 서식 | 결과 | +|---|---| +| 표 처방전 | **여전히 숫자 틀림** (`250mg`을 용량으로) | +| scattered 영수증 | 8약을 13개로 과추출 (성분명 분리) | + +→ gpt-4o-mini는 좌표 리스트로 공간 추론을 못 함. + +### 2-3. gpt-4o + 좌표 + **프롬프트 개선** + +프롬프트 1차는 "제형어 빼라"고 해서 `아모잘탄정`→`아모잘탄` 처럼 접미사까지 제거됨. +→ "제형 접미사는 이름의 일부, 절대 떼지 마라 / 용량 표기·괄호 성분명만 제거 / 성분명만 있는 줄은 약 아님" 으로 수정. + +| fixture | 파서 | gpt-4o | +|---|---|---| +| 별표 영수증 | 4/4 | 4/4 | +| 합성 표 처방전 | 5/5 | 5/5 | +| 압축 약봉투(합성) | 4/4 | 4/4 | +| 별표 약봉투(실제) | 4/4 | 3/4 (`코푸시럽` 용량 `1포` vs `1ml`) | +| **압축 약봉투(실제, 9약)** | 9/9 | **9/9** | +| **저화질 표 처방전** | **4/4** | **0/4** (숫자 매핑 실패) | +| **grid 영수증** | 4/5 | **5/5** — 파서가 놓친 약을 잡음 | +| scattered 영수증 | 3/8 | 이름 8/8 (횟수는 오독) | + +--- + +## 3. 결정 — 하이브리드 + +측정이 말하는 것: + +| 서식 | 이긴 쪽 | 이유 | +|---|---|---| +| 보험코드 줄 처방전 (저화질) | **파서** | 좌표를 알고리즘으로 정확히 계산. LLM은 좌표 텍스트로 표를 못 읽음 | +| 약봉투 | 무승부 | 파서 이미 정확(무료·즉시), LLM도 gpt-4o면 거의 동급 | +| 영수증 / 미지원 서식 | **LLM** | 파서가 놓치는 약을 잡고 이름이 깨끗 | + +→ **"LLM으로 전면 전환"도 "파서 유지"도 아닌 하이브리드**: + +``` +OCR → 보험코드 줄(\d{8,10}\s+약이름)이 2개 이상인가? + ├─ YES → 병원 처방전 → 파서 (parseByPrescriptionCode, 좌표) + └─ NO → 약봉투/영수증/그 외 → gpt-4o (좌표 텍스트 → 약 JSON) + ↓ LLM 실패·약 0개 + 파서 폴백 +``` + +### 왜 "보험코드 줄"이 라우팅 신호인가 + +한국 처방전은 「국민건강보험 요양급여 규칙 별지 제9호」 법정 서식이라 +`보험코드(8~10자리) + 제품명 + (내복/외용)` 줄이 고정으로 들어간다. +병원 EMR이 무엇이든 이 형식은 같고, 헤더 텍스트가 OCR로 뭉개져도 이 줄은 살아있다. +약봉투·영수증에는 없으므로 깔끔하게 갈린다. + +--- + +## 4. 구현 + +### 신규 + +| 파일 | 역할 | +|---|---| +| `service/DrugExtractor` | `List extract(fields)`. 실패 시 예외 대신 빈 리스트(폴백 신호) | +| `service/LlmDrugExtractor` | `DrugExtractor` 구현. 키 미설정·API 오류 → 빈 리스트. sanity check(횟수 1~6, 일수 1~90 벗어나면 null) | +| `client/OpenAiClient` | fields → `"텍스트 @(x,y)"` 좌표 목록 → Chat Completions(`gpt-4o`, `temperature 0`, `json_object`) → `{"drugs":[...]}` | + +### 수정 + +| 파일 | 변경 | +|---|---| +| `OcrParser` | `hasCodedPrescriptionLines(fields)` public 추가 (라우팅 신호) | +| `OcrCommandService` | 하이브리드 라우팅 + `usedLlm`/`method` 로깅 | +| `application.yml` | `openai.api-key/base-url/model` (`OPENAI_API_KEY` 없으면 LLM 자동 비활성 → 파서 전용) | + +### 프롬프트 핵심 (`OpenAiClient.SYSTEM_PROMPT`) + +- 입력은 `텍스트 @(x,y)` 목록, y 차이 15 이내면 같은 행 +- 약 이름 오른쪽 같은 행의 한 자리 숫자를 x순으로 [투약량, 횟수, 일수] +- 제형 접미사(정·캡슐·서방정)는 이름의 일부 — **떼지 마라** +- 용량 표기(500mg)·괄호 성분명·제형만 나타내는 단어(코팅정)·성분명만 있는 줄은 제외 +- "교부일로부터 N일"은 총투약일수 아님 + +### 응답/스키마 + +`OcrResultResponse.parsedDrugs` 형식 불변. `ocr_result`엔 여전히 첫 약만 저장(3-3 스키마). + +--- + +## 5. 측정 재현 + +```bash +# 키 없으면 자동 스킵 +OPENAI_API_KEY=sk-... ./gradlew test --tests "*LlmDrugExtractorComparisonTest" -Dgroups=integration +``` + +출력: fixture별 `파서 / LLM / 하이브리드` 정확도 + 라우팅 경로. + +단위 테스트(키 불필요): +```bash +./gradlew test --tests "*OcrCommandServiceTest" --tests "*OcrParserRegressionTest" +``` + +--- + +## 6. 알려진 한계 · 후속 + +- **LLM 비결정성**: `temperature 0`이어도 gpt-4o가 run마다 미세하게 다른 출력(이름에 용량이 붙었다 안 붙었다). 결정론적인 파서와 다른 성질. 사용자 확인 UI가 최종 방어선이지만 "같은 사진 다른 결과" 리포트 가능성. +- **비용**: gpt-4o 호출당 약 15~20원. 약봉투·영수증이 LLM 경로라 자주 호출됨. 볼륨 커지면 gpt-4o-mini + 프롬프트 강화 또는 캐싱 검토. +- **이름 정규화 미완**: `지스로먹스장` → `지스로맥스정` 은 gpt-4o도 확실히 못 함. ES(`druginfo`) 대조로 교정 = 다음 이슈. 단 `druginfo` 4,745건이라 커버리지 제한적. +- **저화질 표 처방전**: 좌표가 흩어지면 파서도 흔들림(grid 영수증 4/5). 이미지→비전 LLM은 별도 검토. +- `ocr_result`에 첫 약만 저장 → 다중 약 이력 연결 안 됨 (스키마 개편 별도). From ce56adcfc134cc8e0849c913c3bfccdd434cafd6 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Sun, 6 Sep 2026 21:23:53 +0900 Subject: [PATCH 07/11] =?UTF-8?q?refactor:=20=ED=95=98=EC=9D=B4=EB=B8=8C?= =?UTF-8?q?=EB=A6=AC=EB=93=9C=20=EB=9D=BC=EC=9A=B0=ED=8C=85=20=EC=8B=A0?= =?UTF-8?q?=ED=98=B8=EB=A5=BC=20=EC=84=9C=EC=8B=9D=EB=B3=84=20=EA=B3=A0?= =?UTF-8?q?=EC=9C=A0=20=EB=AC=B8=EA=B5=AC=EB=A1=9C=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hasCodedPrescriptionLines(보험코드 줄만 확인) → isPrescription: 처방전 신호("처방전"/"처방 의약품"/"교부번호·교부일"/보험코드 줄) AND 약봉투 신호("복약안내"/별표/"N정씩N회N일분") 없음 AND 영수증 신호("약제비"/"계산서"/"본인부담금") 없음 보험코드를 인쇄하지 않는 병원 EMR의 처방전도 파서로 라우팅되도록. fixture 8종 재측정: 처방전 2종 모두 파서(1.00), 약봉투·영수증은 LLM. Co-Authored-By: Claude Sonnet 5 --- docs/kangcheolung/issue-100-ocr-llm-hybrid.md | 32 +++++++++++++------ .../domain/ocrresult/service/OcrParser.java | 28 +++++++++++++--- .../service/command/OcrCommandService.java | 6 ++-- .../LlmDrugExtractorComparisonTest.java | 2 +- .../command/OcrCommandServiceTest.java | 8 ++--- 5 files changed, 54 insertions(+), 22 deletions(-) diff --git a/docs/kangcheolung/issue-100-ocr-llm-hybrid.md b/docs/kangcheolung/issue-100-ocr-llm-hybrid.md index 412b809..e16c261 100644 --- a/docs/kangcheolung/issue-100-ocr-llm-hybrid.md +++ b/docs/kangcheolung/issue-100-ocr-llm-hybrid.md @@ -82,26 +82,40 @@ fixture 9종(실제 사진 5 + 합성 4)의 `rawText`/`fields`를 각 방식에 | 서식 | 이긴 쪽 | 이유 | |---|---|---| -| 보험코드 줄 처방전 (저화질) | **파서** | 좌표를 알고리즘으로 정확히 계산. LLM은 좌표 텍스트로 표를 못 읽음 | +| 병원 처방전 (표 서식) | **파서** | 좌표를 알고리즘으로 정확히 계산. LLM은 좌표 텍스트로 표를 못 읽음 | | 약봉투 | 무승부 | 파서 이미 정확(무료·즉시), LLM도 gpt-4o면 거의 동급 | | 영수증 / 미지원 서식 | **LLM** | 파서가 놓치는 약을 잡고 이름이 깨끗 | → **"LLM으로 전면 전환"도 "파서 유지"도 아닌 하이브리드**: ``` -OCR → 보험코드 줄(\d{8,10}\s+약이름)이 2개 이상인가? - ├─ YES → 병원 처방전 → 파서 (parseByPrescriptionCode, 좌표) +OCR → 병원 처방전인가? (isPrescription) + ├─ YES → 파서 (좌표 알고리즘) └─ NO → 약봉투/영수증/그 외 → gpt-4o (좌표 텍스트 → 약 JSON) ↓ LLM 실패·약 0개 파서 폴백 ``` -### 왜 "보험코드 줄"이 라우팅 신호인가 +### 왜 이 신호인가 — 서식별 고유 문구 -한국 처방전은 「국민건강보험 요양급여 규칙 별지 제9호」 법정 서식이라 -`보험코드(8~10자리) + 제품명 + (내복/외용)` 줄이 고정으로 들어간다. -병원 EMR이 무엇이든 이 형식은 같고, 헤더 텍스트가 OCR로 뭉개져도 이 줄은 살아있다. -약봉투·영수증에는 없으므로 깔끔하게 갈린다. +약봉투·처방전·영수증은 발행처(약국/병원/약국)가 달라 고유 문구가 겹치지 않는다: + +| 서식 | 고유 문구 | +|---|---| +| **처방전** | "처방전" 제목 / "처방 의약품" / "교부번호"·"교부일" / 보험코드 줄(`\d{8,10}\s+약이름`) | +| **약봉투** | "복약안내" / `*약이름` 별표 / "N정씩N회N일분" | +| **영수증** | "약제비" / "계산서" / "본인부담금" | + +```java +isPrescription = 처방전 신호 있음 AND 약봉투 신호 없음 AND 영수증 신호 없음 +``` + +- 뉴스 처방전 → "처 방 전" + "교부일" + 보험코드 → 파서 ✅ +- 향촌 영수증 → "처방전교부번호" 있지만 `1캡슐씩2회7일분`(약봉투 신호) → LLM ✅ +- 필독 영수증 → "약제비 계산서" → LLM ✅ +- 약봉투(별표/압축) → 별표/압축 신호 → LLM ✅ + +보험코드 줄 하나만 보면 코드를 인쇄 안 하는 병원 EMR에서 처방전을 놓친다. 그래서 "처방전" 제목·"교부" 등 여러 신호를 OR로 묶고, 약봉투·영수증 신호로 배제한다. --- @@ -119,7 +133,7 @@ OCR → 보험코드 줄(\d{8,10}\s+약이름)이 2개 이상인가? | 파일 | 변경 | |---|---| -| `OcrParser` | `hasCodedPrescriptionLines(fields)` public 추가 (라우팅 신호) | +| `OcrParser` | `isPrescription(fields)` public 추가 (서식별 고유 문구로 처방전 판정) | | `OcrCommandService` | 하이브리드 라우팅 + `usedLlm`/`method` 로깅 | | `application.yml` | `openai.api-key/base-url/model` (`OPENAI_API_KEY` 없으면 LLM 자동 비활성 → 파서 전용) | diff --git a/src/main/java/com/piuda/callcare/domain/ocrresult/service/OcrParser.java b/src/main/java/com/piuda/callcare/domain/ocrresult/service/OcrParser.java index 08d704e..48152e1 100644 --- a/src/main/java/com/piuda/callcare/domain/ocrresult/service/OcrParser.java +++ b/src/main/java/com/piuda/callcare/domain/ocrresult/service/OcrParser.java @@ -89,14 +89,32 @@ public class OcrParser { private static final List DAYS_KEYWORDS = List.of("투약일수", "복용일수", "일수"); /** - * 보험코드 줄이 있는 병원 처방전이면 true. + * 병원 처방전(약국에서 주는 약봉투·영수증이 아닌)이면 true. *

- * 이 서식은 저화질이라 헤더가 뭉개져도 좌표 알고리즘(파서)이 숫자 컬럼을 정확히 잡는 반면 + * 처방전은 표 서식이라 저화질이어도 좌표 알고리즘(파서)이 숫자 컬럼을 정확히 잡는 반면 * LLM은 좌표 텍스트로 표를 못 읽는다(실측). 하이브리드 라우팅에서 "파서로 보낼 것" 신호. - * 약봉투·영수증은 여기 걸리지 않으므로 LLM 경로로 간다. + *

+ * 서식별 고유 문구로 구분한다: + *

    + *
  • 처방전: "처방전" 제목 / "처방 의약품" / "교부번호"·"교부일" / 보험코드 줄
  • + *
  • 약봉투: "복약안내" / {@code *약이름} 별표 / "N정씩N회N일분"
  • + *
  • 영수증: "약제비" / "계산서" / "본인부담금"
  • + *
*/ - public boolean hasCodedPrescriptionLines(List fields) { - return hasPrescriptionCodeLines(buildRawText(fields)); + public boolean isPrescription(List fields) { + String t = buildRawText(fields); + + boolean prescriptionSignal = hasPrescriptionCodeLines(t) + || t.contains("처방전") + || t.contains("처방 의약품") || t.contains("처방의약품") + || t.contains("교부번호") || t.contains("교부일"); + + boolean drugBagSignal = t.contains("복약안내") || t.contains("약봉투") + || isPharmacyReceipt(t) || isTextSequentialMulti(t); + + boolean receiptSignal = t.contains("약제비") || t.contains("계산서") || t.contains("본인부담금"); + + return prescriptionSignal && !drugBagSignal && !receiptSignal; } public OcrParseResult parse(List fields, OcrType ocrType) { diff --git a/src/main/java/com/piuda/callcare/domain/ocrresult/service/command/OcrCommandService.java b/src/main/java/com/piuda/callcare/domain/ocrresult/service/command/OcrCommandService.java index ca3d74d..d4fd50a 100644 --- a/src/main/java/com/piuda/callcare/domain/ocrresult/service/command/OcrCommandService.java +++ b/src/main/java/com/piuda/callcare/domain/ocrresult/service/command/OcrCommandService.java @@ -46,11 +46,11 @@ public OcrResultResponse processOcr(Long userId, Long seniorId, MultipartFile im OcrParseResult parseResult = ocrParser.parse(ocrCallResult.fields(), ocrType); // 하이브리드 라우팅 (실측 기반): - // - 보험코드 줄 처방전: 저화질에서 파서(좌표)가 LLM보다 정확 → 파서 결과 사용 - // - 약봉투/영수증/그 외: LLM 추출, 실패 시 파서 폴백 + // - 병원 처방전: 표 서식이라 파서(좌표)가 LLM보다 정확 → 파서 결과 사용 + // - 약봉투/영수증/그 외: LLM 추출 (이름 정규화·서식 무관), 실패 시 파서 폴백 List parsedDrugs; String method; - if (ocrParser.hasCodedPrescriptionLines(ocrCallResult.fields()) && !parseResult.parsedDrugs().isEmpty()) { + if (ocrParser.isPrescription(ocrCallResult.fields()) && !parseResult.parsedDrugs().isEmpty()) { parsedDrugs = parseResult.parsedDrugs(); method = "파서(처방전)"; } else { diff --git a/src/test/java/com/piuda/callcare/domain/ocrresult/service/LlmDrugExtractorComparisonTest.java b/src/test/java/com/piuda/callcare/domain/ocrresult/service/LlmDrugExtractorComparisonTest.java index 71cba7e..27564b7 100644 --- a/src/test/java/com/piuda/callcare/domain/ocrresult/service/LlmDrugExtractorComparisonTest.java +++ b/src/test/java/com/piuda/callcare/domain/ocrresult/service/LlmDrugExtractorComparisonTest.java @@ -41,7 +41,7 @@ class LlmDrugExtractorComparisonTest { List byParser = parser.parse(fields, type).parsedDrugs(); List byLlm = llm.extract(fields); - boolean useParser = parser.hasCodedPrescriptionLines(fields) && !byParser.isEmpty(); + boolean useParser = parser.isPrescription(fields) && !byParser.isEmpty(); List hybrid = useParser ? byParser : (byLlm.isEmpty() ? byParser : byLlm); String route = useParser ? "파서(처방전)" : (byLlm.isEmpty() ? "파서(폴백)" : "LLM"); diff --git a/src/test/java/com/piuda/callcare/domain/ocrresult/service/command/OcrCommandServiceTest.java b/src/test/java/com/piuda/callcare/domain/ocrresult/service/command/OcrCommandServiceTest.java index b461ef8..b46409e 100644 --- a/src/test/java/com/piuda/callcare/domain/ocrresult/service/command/OcrCommandServiceTest.java +++ b/src/test/java/com/piuda/callcare/domain/ocrresult/service/command/OcrCommandServiceTest.java @@ -58,11 +58,11 @@ private void commonStubs() { } @Test - @DisplayName("보험코드 줄 처방전이면 LLM을 부르지 않고 파서 결과를 쓴다") + @DisplayName("병원 처방전이면 LLM을 부르지 않고 파서 결과를 쓴다") void 처방전은_파서_사용() { // Given commonStubs(); - given(ocrParser.hasCodedPrescriptionLines(any())).willReturn(true); + given(ocrParser.isPrescription(any())).willReturn(true); // When ocrCommandService.processOcr(1L, 1L, image(), OcrType.PRESCRIPTION); @@ -77,7 +77,7 @@ private void commonStubs() { void 약봉투는_LLM_사용() { // Given commonStubs(); - given(ocrParser.hasCodedPrescriptionLines(any())).willReturn(false); + given(ocrParser.isPrescription(any())).willReturn(false); given(drugExtractor.extract(any())).willReturn(List.of(LLM_DRUG)); // When @@ -92,7 +92,7 @@ private void commonStubs() { void LLM_실패시_파서_폴백() { // Given commonStubs(); - given(ocrParser.hasCodedPrescriptionLines(any())).willReturn(false); + given(ocrParser.isPrescription(any())).willReturn(false); given(drugExtractor.extract(any())).willReturn(List.of()); // When From c23fc6cd1c29c9e6289bbaa4fc1a4af1292e3c5b Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Sun, 6 Sep 2026 21:28:23 +0900 Subject: [PATCH 08/11] =?UTF-8?q?test:=20OCR=20=EC=B6=94=EC=B6=9C=20?= =?UTF-8?q?=EB=B2=A4=EC=B9=98=EB=A5=BC=20=ED=91=9C=EB=A1=9C=20=EC=B6=9C?= =?UTF-8?q?=EB=A0=A5=20+=20scripts/ocr-bench.sh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LlmDrugExtractorComparisonTest를 @TestFactory → 단일 @Test로 바꿔 fixture별 파서/LLM/하이브리드 exactR + 평균을 한 표로 출력. scripts/ocr-bench.sh: 파서 단위 테스트 + (키 있으면) LLM 벤치 표를 뽑는 래퍼. Co-Authored-By: Claude Sonnet 5 --- scripts/ocr-bench.sh | 40 +++++++++++++ .../LlmDrugExtractorComparisonTest.java | 58 +++++++++++-------- 2 files changed, 75 insertions(+), 23 deletions(-) create mode 100755 scripts/ocr-bench.sh diff --git a/scripts/ocr-bench.sh b/scripts/ocr-bench.sh new file mode 100755 index 0000000..8ac1578 --- /dev/null +++ b/scripts/ocr-bench.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# OCR 추출 벤치 — 파서 vs LLM vs 하이브리드 정확도를 fixture 9종으로 측정해 표로 출력. +# +# 사용법: +# OPENAI_API_KEY=sk-... scripts/ocr-bench.sh +# OPENAI_API_KEY=sk-... OPENAI_MODEL=gpt-4o-mini scripts/ocr-bench.sh +# +# 키가 없으면 통합 테스트가 스킵되고 파서 단위 테스트 결과만 나온다. +set -euo pipefail +cd "$(dirname "$0")/.." + +# .env에 OPENAI_API_KEY가 있으면 자동 로드 +if [[ -z "${OPENAI_API_KEY:-}" && -f .env ]]; then + export OPENAI_API_KEY="$(grep -E '^OPENAI_API_KEY=' .env | cut -d= -f2- || true)" +fi + +echo "── 파서 회귀 테스트 (키 불필요) ──" +./gradlew test --tests "*OcrParserRegressionTest" --tests "*OcrCommandServiceTest" -q 2>&1 | tail -3 || true + +if [[ -z "${OPENAI_API_KEY:-}" ]]; then + echo + echo "OPENAI_API_KEY 없음 → LLM 벤치 스킵. 키를 주면 파서 vs LLM vs 하이브리드 표가 나옵니다." + exit 0 +fi + +echo +echo "── 파서 vs LLM vs 하이브리드 벤치 (${OPENAI_MODEL:-gpt-4o} 호출, ~\$0.15) ──" +./gradlew test --tests "*LlmDrugExtractorComparisonTest" -Dgroups=integration --rerun -q 2>&1 | tail -2 || true + +# 테스트가 System.out으로 찍은 표를 결과 XML에서 뽑는다 +python3 - <<'PY' +import glob, html, re +for f in glob.glob("build/test-results/test/*LlmDrugExtractorComparisonTest.xml"): + s = open(f).read() + for m in re.findall(r'', s, re.S): + for line in html.unescape(m).splitlines(): + if "[Test worker]" in line or "spring-jcl" in line or "io.netty" in line: + continue + print(line) +PY diff --git a/src/test/java/com/piuda/callcare/domain/ocrresult/service/LlmDrugExtractorComparisonTest.java b/src/test/java/com/piuda/callcare/domain/ocrresult/service/LlmDrugExtractorComparisonTest.java index 27564b7..0f23d03 100644 --- a/src/test/java/com/piuda/callcare/domain/ocrresult/service/LlmDrugExtractorComparisonTest.java +++ b/src/test/java/com/piuda/callcare/domain/ocrresult/service/LlmDrugExtractorComparisonTest.java @@ -9,48 +9,60 @@ import com.piuda.callcare.domain.ocrresult.fixture.OcrFixtureLoader.FixtureCase; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Tag; -import org.junit.jupiter.api.TestFactory; -import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.reactive.function.client.WebClient; import java.util.List; -import java.util.stream.Stream; /** - * 실제 OpenAI를 호출해 fixture 9종에서 파서 vs LLM 추출 정확도를 비교한다(리포트). + * 실제 OpenAI를 호출해 fixture 9종에서 파서 / LLM / 하이브리드 추출 정확도를 비교한다(리포트). * OPENAI_API_KEY 환경변수가 있을 때만 실행. 호출 비용이 발생하므로 @Tag("integration"). + *

+ * exactR = 4개 필드(이름·1회량·1일횟수·총일수)가 정답과 완전히 일치하는 약의 비율. */ @Tag("integration") @EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+") -@DisplayName("파서 vs LLM 추출 비교") +@DisplayName("OCR 추출 벤치 — 파서 vs LLM vs 하이브리드") class LlmDrugExtractorComparisonTest { private final OcrParser parser = new OcrParser(); private final LlmDrugExtractor llm = buildLlmExtractor(); - @TestFactory - Stream 비교_리포트() { - return OcrFixtureLoader.loadManifest().fixtures().stream() - .filter(fc -> OcrFixtureLoader.exists(fc.file())) - .map(fc -> DynamicTest.dynamicTest(fc.file(), () -> { - List fields = OcrFixtureLoader.loadFields(fc.file()); - OcrType type = OcrType.valueOf(fc.ocrType()); + @Test + void 벤치_리포트() { + String model = System.getenv().getOrDefault("OPENAI_MODEL", "gpt-4o"); + System.out.printf("%n=== OCR 추출 벤치 (LLM = %s) ===%n", model); + System.out.printf("%-34s %-12s %6s %6s %6s%n", "fixture", "하이브리드경로", "파서", "LLM", "하이브리드"); + System.out.println("-".repeat(78)); - List byParser = parser.parse(fields, type).parsedDrugs(); - List byLlm = llm.extract(fields); + double sumParser = 0, sumLlm = 0, sumHybrid = 0; + int n = 0; - boolean useParser = parser.isPrescription(fields) && !byParser.isEmpty(); - List hybrid = useParser ? byParser - : (byLlm.isEmpty() ? byParser : byLlm); - String route = useParser ? "파서(처방전)" : (byLlm.isEmpty() ? "파서(폴백)" : "LLM"); + for (FixtureCase fc : OcrFixtureLoader.loadManifest().fixtures()) { + if (!OcrFixtureLoader.exists(fc.file())) continue; + List fields = OcrFixtureLoader.loadFields(fc.file()); + OcrType type = OcrType.valueOf(fc.ocrType()); - System.out.printf("[%s] 기대 %d | 파서 %.2f | LLM %.2f | 하이브리드 %.2f (%s)%n", - fc.file(), fc.expected().size(), - recall(fc.expected(), byParser), recall(fc.expected(), byLlm), - recall(fc.expected(), hybrid), route); - })); + List byParser = parser.parse(fields, type).parsedDrugs(); + List byLlm = llm.extract(fields); + + boolean useParser = parser.isPrescription(fields) && !byParser.isEmpty(); + List hybrid = useParser ? byParser : (byLlm.isEmpty() ? byParser : byLlm); + String route = useParser ? "파서(처방전)" : (byLlm.isEmpty() ? "파서(폴백)" : "LLM"); + + double p = recall(fc.expected(), byParser); + double l = recall(fc.expected(), byLlm); + double h = recall(fc.expected(), hybrid); + sumParser += p; sumLlm += l; sumHybrid += h; n++; + + System.out.printf("%-34s %-12s %6.2f %6.2f %6.2f%n", fc.file(), route, p, l, h); + } + + System.out.println("-".repeat(78)); + System.out.printf("%-34s %-12s %6.2f %6.2f %6.2f%n", "평균", "", sumParser / n, sumLlm / n, sumHybrid / n); + System.out.println("\n(exactR = 정답과 4개 필드 완전 일치한 약의 비율. LLM은 temperature 0이어도 run마다 소폭 변동)"); } private double recall(List expected, List actual) { From 49fff3146223addd6960387251b1f5725ce41674 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Sun, 6 Sep 2026 21:34:59 +0900 Subject: [PATCH 09/11] =?UTF-8?q?test:=20=EB=8F=85=EB=A6=BD=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=20OCR=20=EB=B2=A4=EC=B9=98=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A6=BD=ED=8A=B8=20scripts/ocr-bench.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 파서 결과는 OcrParserRegressionTest 리포트에서, LLM은 스크립트가 직접 OpenAI 호출. 하이브리드 라우팅(isPrescription)도 재현. fixture별 파서/LLM/하이브리드 exactR 표 출력. OPENAI_API_KEY=sk-... RUNS=3 python3 scripts/ocr-bench.py RUNS로 LLM 비결정성 평균. 키 없으면 파서 열만. gradle 래퍼(ocr-bench.sh)는 이걸로 대체. Co-Authored-By: Claude Sonnet 5 --- scripts/ocr-bench.py | 180 +++++++++++++++++++++++++++++++++++++++++++ scripts/ocr-bench.sh | 40 ---------- 2 files changed, 180 insertions(+), 40 deletions(-) create mode 100644 scripts/ocr-bench.py delete mode 100755 scripts/ocr-bench.sh diff --git a/scripts/ocr-bench.py b/scripts/ocr-bench.py new file mode 100644 index 0000000..3f9264b --- /dev/null +++ b/scripts/ocr-bench.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +""" +OCR 약 추출 벤치 — fixture 9종에서 파서 / LLM(gpt-4o) / 하이브리드 정확도를 표로 출력한다. + +- 파서 결과: OcrParserRegressionTest(Java, 결정론적)를 한 번 돌려 그 리포트에서 뽑는다. +- LLM 결과: 이 스크립트가 직접 OpenAI를 호출한다(좌표 텍스트 방식, OpenAiClient와 동일). +- 하이브리드: isPrescription 라우팅(서식별 고유 문구)을 여기서 재현한다. + +사용법: + OPENAI_API_KEY=sk-... python3 scripts/ocr-bench.py + OPENAI_API_KEY=sk-... OPENAI_MODEL=gpt-4o-mini python3 scripts/ocr-bench.py + python3 scripts/ocr-bench.py # 키 없으면 파서 열만 + +exactR = 정답(manifest)과 4개 필드(이름·1회량·1일횟수·총일수)가 완전히 일치한 약의 비율. +""" +import json +import os +import re +import subprocess +import sys +import urllib.request + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +FIX = f"{ROOT}/src/test/resources/ocr/fixtures" +MANIFEST = f"{ROOT}/src/test/resources/ocr/expected/manifest.json" +MODEL = os.environ.get("OPENAI_MODEL", "gpt-4o") + +API_KEY = os.environ.get("OPENAI_API_KEY") +if not API_KEY and os.path.exists(f"{ROOT}/.env"): + for line in open(f"{ROOT}/.env"): + if line.startswith("OPENAI_API_KEY="): + API_KEY = line.split("=", 1)[1].strip() + +SYSTEM_PROMPT = """너는 한국 병원 처방전·약국 약봉투의 OCR 결과에서 복용할 약 목록을 뽑는 도우미다. +입력은 "텍스트 @(x,y)" 줄 목록이다. y 차이가 15 이내면 같은 행. +약 이름 오른쪽 같은 행의 한 자리 숫자를 x 작은 순서대로 [1회 투약량, 1일 투여횟수, 총 투약일수]. +"1정씩2회5일분" 압축 표기는 그대로 파싱. +drugName: 제형 접미사(정/캡슐/서방정)는 이름의 일부 - 절대 떼지 마라. 용량 표기(mg)·괄호 성분명·제형만 나타내는 단어는 빼라. 성분명만 있는 줄은 약 아님. +dosagePerTime: 단위 포함 "1정"/"0.5정"/"5ml", 모르면 null. timesPerDay: 정수, 모르면 null. +totalDays: 정수. "교부일로부터 N일"은 총투약일수 아님. 모르면 null. +제외: 주의사항, 병원·약국명, 이름, 금액, 날짜, 보험코드(8~10자리). +아래 JSON 형식으로만: {"drugs":[{"drugName":"...","dosagePerTime":"...","timesPerDay":0,"totalDays":0}]}""" + + +def raw_text(fields): + return "".join(f["inferText"] + ("\n" if f.get("lineBreak") else " ") for f in fields).strip() + + +def coord_text(fields): + lines = [] + for f in fields: + if not f.get("inferText", "").strip(): + continue + vs = (f.get("boundingPoly") or {}).get("vertices", []) + cx = int(sum(v["x"] for v in vs) / len(vs)) if vs else 0 + cy = int(sum(v["y"] for v in vs) / len(vs)) if vs else 0 + lines.append((cy, cx, f["inferText"].strip())) + lines.sort() + return "\n".join(f"{t} @({x},{y})" for y, x, t in lines) + + +_DRUG_SUFFIX = r"(?:정|캡슐|캅셀|시럽|액|연고|크림|주사|산|패치)" + + +def is_prescription(t): + code = len(re.findall(r"\d{8,10}\s+[가-힣A-Za-z]", t)) >= 2 + compact = len(re.findall(r"\d+(?:\.\d+)?\s*(?:정|캡슐|캅셀|ml|mg|g|포)\s*씩\s*\d+\s*회\s*\d+\s*일분", t)) >= 2 + # 약봉투의 *약이름 — 별표 뒤 단어가 약 접미사로 끝나야 함 (Java STARRED_DRUG_NAME_PATTERN과 동일) + star = re.search(r"\*[가-힣a-zA-Z][가-힣a-zA-Z0-9]*" + _DRUG_SUFFIX, t) is not None + rx = code or any(k in t for k in ("처방전", "처방 의약품", "처방의약품", "교부번호", "교부일")) + bag = ("복약안내" in t) or ("약봉투" in t) or star or compact + receipt = any(k in t for k in ("약제비", "계산서", "본인부담금")) + return rx and not bag and not receipt + + +def call_llm(fields): + body = json.dumps({ + "model": MODEL, "temperature": 0, + "response_format": {"type": "json_object"}, + "messages": [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": coord_text(fields)}, + ], + }).encode() + req = urllib.request.Request( + "https://api.openai.com/v1/chat/completions", data=body, + headers={"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=30) as r: + content = json.loads(r.read())["choices"][0]["message"]["content"] + drugs = json.loads(content).get("drugs", []) + out = [] + for d in drugs: + name = (d.get("drugName") or "").strip() + if not name: + continue + tpd = d.get("timesPerDay") + tot = d.get("totalDays") + out.append(( + name, + (d.get("dosagePerTime") or None), + tpd if isinstance(tpd, int) and 1 <= tpd <= 6 else None, + tot if isinstance(tot, int) and 1 <= tot <= 90 else None, + )) + return out + + +def norm(d): + return (d["drugName"], d.get("dosagePerTime"), d.get("timesPerDay"), d.get("totalDays")) + + +def recall(expected, actual): + if not expected: + return 0.0 + aset = set(actual) + hit = sum(1 for e in expected if norm(e) in aset) + return hit / len(expected) + + +def parser_recalls(): + """OcrParserRegressionTest를 돌려 fixture별 exact R을 뽑는다.""" + subprocess.run(["./gradlew", "test", "--tests", "*OcrParserRegressionTest", "--rerun", "-q"], + cwd=ROOT, capture_output=True) + res = {} + for fn in os.listdir(f"{ROOT}/build/test-results/test"): + if "OcrParserRegressionTest" not in fn: + continue + s = open(f"{ROOT}/build/test-results/test/{fn}").read() + for cdata in re.findall(r"", s, re.S): + for m in re.findall(r"\[([\w.]+\.json)\] exact P=[\d.]+ R=([\d.]+)", cdata): + res[m[0]] = float(m[1]) + return res + + +def main(): + runs = int(os.environ.get("RUNS", "1")) + manifest = json.load(open(MANIFEST))["fixtures"] + fixtures = [fc for fc in manifest if os.path.exists(f"{FIX}/{fc['file']}")] + print(f"fixture {len(fixtures)}종 · LLM={MODEL} · {runs} run 평균" if API_KEY else f"fixture {len(fixtures)}종 · 파서만 (키 없음)") + + p_rec = parser_recalls() + + W = 38 + print(f"\n{'fixture':<{W}}{'라우팅':<11}{'파서':>7}{'LLM':>7}{'하이브리드':>10}") + print("-" * (W + 35)) + sp = sl = sh = 0.0 + for fc in fixtures: + fields = json.load(open(f"{FIX}/{fc['file']}"))["images"][0]["fields"] + exp = fc["expected"] + route_parser = is_prescription(raw_text(fields)) + p = p_rec.get(fc["file"], float("nan")) + + if not API_KEY: + route = "파서(처방전)" if route_parser else "LLM" + print(f"{fc['file']:<{W}}{route:<11}{p:>7.2f}{'—':>7}{'—':>9}") + continue + + ls = [] + for _ in range(runs): + try: + llm = call_llm(fields) + except Exception as e: + print(f" ! {fc['file']} LLM 오류: {e}", file=sys.stderr) + llm = [] + ls.append((recall(exp, llm), bool(llm))) + l = sum(x[0] for x in ls) / runs + any_drug = any(x[1] for x in ls) + hybrid_val = p if route_parser else (l if any_drug else p) + route = "파서(처방전)" if route_parser else ("LLM" if any_drug else "파서(폴백)") + print(f"{fc['file']:<{W}}{route:<11}{p:>7.2f}{l:>7.2f}{hybrid_val:>9.2f}") + sp += p; sl += l; sh += hybrid_val + + if API_KEY: + n = len(fixtures) + print("-" * (W + 35)) + print(f"{'평균':<{W}}{'':<11}{sp/n:>7.2f}{sl/n:>7.2f}{sh/n:>9.2f}") + print("\nexactR = 정답과 4개 필드 완전 일치 비율. LLM은 temperature 0이어도 run마다 소폭 변동 (RUNS=3 로 평균 권장).") + + +if __name__ == "__main__": + main() diff --git a/scripts/ocr-bench.sh b/scripts/ocr-bench.sh deleted file mode 100755 index 8ac1578..0000000 --- a/scripts/ocr-bench.sh +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env bash -# OCR 추출 벤치 — 파서 vs LLM vs 하이브리드 정확도를 fixture 9종으로 측정해 표로 출력. -# -# 사용법: -# OPENAI_API_KEY=sk-... scripts/ocr-bench.sh -# OPENAI_API_KEY=sk-... OPENAI_MODEL=gpt-4o-mini scripts/ocr-bench.sh -# -# 키가 없으면 통합 테스트가 스킵되고 파서 단위 테스트 결과만 나온다. -set -euo pipefail -cd "$(dirname "$0")/.." - -# .env에 OPENAI_API_KEY가 있으면 자동 로드 -if [[ -z "${OPENAI_API_KEY:-}" && -f .env ]]; then - export OPENAI_API_KEY="$(grep -E '^OPENAI_API_KEY=' .env | cut -d= -f2- || true)" -fi - -echo "── 파서 회귀 테스트 (키 불필요) ──" -./gradlew test --tests "*OcrParserRegressionTest" --tests "*OcrCommandServiceTest" -q 2>&1 | tail -3 || true - -if [[ -z "${OPENAI_API_KEY:-}" ]]; then - echo - echo "OPENAI_API_KEY 없음 → LLM 벤치 스킵. 키를 주면 파서 vs LLM vs 하이브리드 표가 나옵니다." - exit 0 -fi - -echo -echo "── 파서 vs LLM vs 하이브리드 벤치 (${OPENAI_MODEL:-gpt-4o} 호출, ~\$0.15) ──" -./gradlew test --tests "*LlmDrugExtractorComparisonTest" -Dgroups=integration --rerun -q 2>&1 | tail -2 || true - -# 테스트가 System.out으로 찍은 표를 결과 XML에서 뽑는다 -python3 - <<'PY' -import glob, html, re -for f in glob.glob("build/test-results/test/*LlmDrugExtractorComparisonTest.xml"): - s = open(f).read() - for m in re.findall(r'', s, re.S): - for line in html.unescape(m).splitlines(): - if "[Test worker]" in line or "spring-jcl" in line or "io.netty" in line: - continue - print(line) -PY From 9487784b4c37b99080e2adf5644bf5d045a9fac5 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Sun, 6 Sep 2026 21:35:20 +0900 Subject: [PATCH 10/11] =?UTF-8?q?docs:=20=EC=B8=A1=EC=A0=95=20=EC=9E=AC?= =?UTF-8?q?=ED=98=84=20=EC=84=B9=EC=85=98=EC=97=90=20scripts/ocr-bench.py?= =?UTF-8?q?=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- docs/kangcheolung/issue-100-ocr-llm-hybrid.md | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/docs/kangcheolung/issue-100-ocr-llm-hybrid.md b/docs/kangcheolung/issue-100-ocr-llm-hybrid.md index e16c261..439e835 100644 --- a/docs/kangcheolung/issue-100-ocr-llm-hybrid.md +++ b/docs/kangcheolung/issue-100-ocr-llm-hybrid.md @@ -154,13 +154,27 @@ isPrescription = 처방전 신호 있음 AND 약봉투 신호 없음 AND 영 ## 5. 측정 재현 ```bash -# 키 없으면 자동 스킵 -OPENAI_API_KEY=sk-... ./gradlew test --tests "*LlmDrugExtractorComparisonTest" -Dgroups=integration +# 파서 / LLM / 하이브리드 exactR 표 (키 없으면 파서 열만) +OPENAI_API_KEY=sk-... RUNS=3 python3 scripts/ocr-bench.py ``` -출력: fixture별 `파서 / LLM / 하이브리드` 정확도 + 라우팅 경로. +``` +fixture 라우팅 파서 LLM 하이브리드 +pharmacy_receipt_starred.json LLM 1.00 1.00 1.00 +table_prescription_synth.json 파서(처방전) 1.00 1.00 1.00 +drug_bag_compact_real.json LLM 1.00 1.00 1.00 +table_prescription_real.json 파서(처방전) 1.00 0.17 1.00 +pharmacy_receipt_grid_real.json LLM 0.80 1.00 1.00 +... +평균 0.90 0.68 0.78 +``` + +- `table_prescription_real` LLM 0.17 → **파서로 라우팅**돼 하이브리드 1.00 +- `pharmacy_receipt_grid_real` 파서 0.80 → **LLM으로 라우팅**돼 1.00 +- `scattered` 하이브리드 0.00 = metric 한계 (정답이 전부 null인데 LLM이 이름 8/8 채움) +- 파서 평균이 높은 건 정답을 파서 출력 기준으로 라벨링해서. LLM의 "다른 형식"(용량 표기 포함)이 불일치로 잡힘 -단위 테스트(키 불필요): +단위 테스트(키 불필요, 결정론적): ```bash ./gradlew test --tests "*OcrCommandServiceTest" --tests "*OcrParserRegressionTest" ``` From a73fb3cd1e0178c9b01a7dc13053b301b6223c7b Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Sun, 6 Sep 2026 21:42:49 +0900 Subject: [PATCH 11/11] =?UTF-8?q?fix:=20CodeRabbit=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=20=E2=80=94=20LLM=20=EC=A0=84=EC=86=A1=20?= =?UTF-8?q?=EC=A0=84=20=EC=97=B0=EB=9D=BD=EC=B2=98=20=EB=A7=88=EC=8A=A4?= =?UTF-8?q?=ED=82=B9,=20https=20=EA=B0=95=EC=A0=9C,=20=EC=A0=95=EC=88=98?= =?UTF-8?q?=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OpenAiClient.toCoordinateText: 외부(OpenAI)로 나가는 텍스트에 PiiMasker.maskContact 적용 (주민번호 + 전화번호). PiiMasker는 저장 전에만 걸려 외부 전송을 못 막았음 - OpenAiClient @PostConstruct: openai.base-url이 https가 아니면 거부 (localhost 예외). http면 API 키·OCR 텍스트가 평문 전송됨 - LlmDrugExtractor.boundedInt: canConvertToInt()는 범위만 확인해 1.5를 통과시킴 → isIntegralNumber() + 숫자 문자열만 허용 Co-Authored-By: Claude Sonnet 5 --- .../domain/ocrresult/client/OpenAiClient.java | 14 ++++++++- .../ocrresult/service/LlmDrugExtractor.java | 13 +++++--- .../piuda/callcare/global/util/PiiMasker.java | 31 ++++++++++++------- .../callcare/global/util/PiiMaskerTest.java | 15 +++++++++ 4 files changed, 56 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/piuda/callcare/domain/ocrresult/client/OpenAiClient.java b/src/main/java/com/piuda/callcare/domain/ocrresult/client/OpenAiClient.java index c44cfcf..3f7625f 100644 --- a/src/main/java/com/piuda/callcare/domain/ocrresult/client/OpenAiClient.java +++ b/src/main/java/com/piuda/callcare/domain/ocrresult/client/OpenAiClient.java @@ -3,6 +3,8 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.piuda.callcare.domain.ocrresult.dto.response.NaverOcrApiResponse; +import com.piuda.callcare.global.util.PiiMasker; +import jakarta.annotation.PostConstruct; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; @@ -70,6 +72,15 @@ public OpenAiClient(WebClient webClient, ObjectMapper objectMapper) { this.objectMapper = objectMapper; } + @PostConstruct + void validateBaseUrl() { + // API 키·OCR 텍스트가 평문으로 나가지 않도록 https만 허용 (로컬 목 서버는 예외) + if (StringUtils.hasText(baseUrl) && !baseUrl.startsWith("https://") + && !baseUrl.startsWith("http://localhost") && !baseUrl.startsWith("http://127.0.0.1")) { + throw new IllegalStateException("openai.base-url must use https: " + baseUrl); + } + } + public boolean isEnabled() { return StringUtils.hasText(apiKey); } @@ -109,6 +120,7 @@ public JsonNode extractDrugs(List fields) throws Exce } // 각 필드를 "텍스트 @(중심x,중심y)" 한 줄로. y→x 순 정렬해 표 구조를 읽기 쉽게. + // 외부(OpenAI)로 나가므로 주민번호·전화번호는 마스킹한다. private String toCoordinateText(List fields) { record Line(String text, int x, int y) {} return fields.stream() @@ -117,7 +129,7 @@ record Line(String text, int x, int y) {} List v = f.boundingPoly() != null ? f.boundingPoly().vertices() : List.of(); int cx = v.isEmpty() ? 0 : (int) v.stream().mapToDouble(NaverOcrApiResponse.Vertex::x).average().orElse(0); int cy = v.isEmpty() ? 0 : (int) v.stream().mapToDouble(NaverOcrApiResponse.Vertex::y).average().orElse(0); - return new Line(f.inferText().trim(), cx, cy); + return new Line(PiiMasker.maskContact(f.inferText().trim()), cx, cy); }) .sorted((a, b) -> a.y() != b.y() ? Integer.compare(a.y(), b.y()) : Integer.compare(a.x(), b.x())) .map(l -> l.text() + " @(" + l.x() + "," + l.y() + ")") diff --git a/src/main/java/com/piuda/callcare/domain/ocrresult/service/LlmDrugExtractor.java b/src/main/java/com/piuda/callcare/domain/ocrresult/service/LlmDrugExtractor.java index 7e0a426..d1da925 100644 --- a/src/main/java/com/piuda/callcare/domain/ocrresult/service/LlmDrugExtractor.java +++ b/src/main/java/com/piuda/callcare/domain/ocrresult/service/LlmDrugExtractor.java @@ -56,11 +56,16 @@ private String text(JsonNode node, String field) { return s.isEmpty() ? null : s; } - // 범위를 벗어나는 값은 환각으로 보고 null 처리 + // 정수가 아니거나(1.5, "약간" 등) 범위를 벗어나는 값은 환각으로 보고 null 처리 private Integer boundedInt(JsonNode node, String field, int max) { JsonNode v = node.get(field); - if (v == null || v.isNull() || !v.canConvertToInt()) return null; - int n = v.asInt(); - return (n >= 1 && n <= max) ? n : null; + if (v == null || v.isNull()) return null; + Integer n = null; + if (v.isIntegralNumber()) { + n = v.asInt(); + } else if (v.isTextual() && v.asText().trim().matches("\\d{1,3}")) { + n = Integer.parseInt(v.asText().trim()); + } + return (n != null && n >= 1 && n <= max) ? n : null; } } diff --git a/src/main/java/com/piuda/callcare/global/util/PiiMasker.java b/src/main/java/com/piuda/callcare/global/util/PiiMasker.java index 38799f0..1b20b4e 100644 --- a/src/main/java/com/piuda/callcare/global/util/PiiMasker.java +++ b/src/main/java/com/piuda/callcare/global/util/PiiMasker.java @@ -3,32 +3,39 @@ import java.util.regex.Pattern; /** - * OCR 결과를 저장하기 전 민감정보를 가리는 유틸. + * OCR 결과를 저장하거나 외부(LLM)로 보내기 전 민감정보를 가리는 유틸. *

- * 주민등록번호만 대상으로 한다. 형식이 고정("6자리 [-] 7자리", 뒷자리 첫 숫자 1~8)이라 - * 정규식으로 안전하게 잡힌다. 하이픈은 OCR이 놓치는 경우가 있어 선택적으로 두고, - * 앞뒤 숫자 경계(lookbehind/lookahead)로 더 긴 숫자열 내부 부분 일치를 막는다. + * 형식이 고정된 항목(주민등록번호, 전화번호)만 정규식으로 잡는다. * 환자 이름·생년월일은 형식이 없어 자동 식별이 어렵고, 저장 허용 범위라 건드리지 않는다. */ public final class PiiMasker { private static final Pattern RESIDENT_NUMBER = Pattern.compile("(?