From b1243ab6337c3687b70f446a06088556140f0d7a Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Sun, 6 Sep 2026 15:32:50 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20OcrResult=EC=97=90=20raw=5Fresponse?= =?UTF-8?q?=20=EC=BB=AC=EB=9F=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Naver OCR 응답 원문(JSON, 좌표 포함)을 저장할 TEXT 컬럼. 실패 케이스 재현·파서 회귀 테스트의 입력으로 쓴다. ddl-auto update라 마이그레이션 파일은 없다. Co-Authored-By: Claude Sonnet 5 --- .../piuda/callcare/domain/ocrresult/entity/OcrResult.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/piuda/callcare/domain/ocrresult/entity/OcrResult.java b/src/main/java/com/piuda/callcare/domain/ocrresult/entity/OcrResult.java index 14e0424..13fcd1a 100644 --- a/src/main/java/com/piuda/callcare/domain/ocrresult/entity/OcrResult.java +++ b/src/main/java/com/piuda/callcare/domain/ocrresult/entity/OcrResult.java @@ -36,6 +36,9 @@ public class OcrResult { @Column(name = "raw_text", columnDefinition = "TEXT") private String rawText; // OCR로 추출된 원본 텍스트 + @Column(name = "raw_response", columnDefinition = "TEXT") + private String rawResponse; // Naver OCR 응답 원문 JSON (좌표 포함, 실패 재현·회귀 테스트용) + @Column(name = "parsed_drug_name") private String parsedDrugName; // OCR 결과에서 추출된 약 이름 @@ -55,11 +58,12 @@ public class OcrResult { private LocalDateTime createdAt; @Builder - public OcrResult(Senior senior, String imageUrl, OcrType ocrType, String rawText) { + public OcrResult(Senior senior, String imageUrl, OcrType ocrType, String rawText, String rawResponse) { this.senior = senior; this.imageUrl = imageUrl; this.ocrType = ocrType; this.rawText = rawText; + this.rawResponse = rawResponse; this.isProcessed = false; this.createdAt = LocalDateTime.now(); } From 456e047a0295d466b392e83c682fc87ca429af94 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Sun, 6 Sep 2026 15:32:50 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20NaverOcrCallResult=20DTO=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 OCR 호출 결과를 파싱용 fields와 저장용 응답 원문으로 함께 담는 record. Co-Authored-By: Claude Sonnet 5 --- .../domain/ocrresult/dto/NaverOcrCallResult.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/main/java/com/piuda/callcare/domain/ocrresult/dto/NaverOcrCallResult.java diff --git a/src/main/java/com/piuda/callcare/domain/ocrresult/dto/NaverOcrCallResult.java b/src/main/java/com/piuda/callcare/domain/ocrresult/dto/NaverOcrCallResult.java new file mode 100644 index 0000000..49596f1 --- /dev/null +++ b/src/main/java/com/piuda/callcare/domain/ocrresult/dto/NaverOcrCallResult.java @@ -0,0 +1,14 @@ +package com.piuda.callcare.domain.ocrresult.dto; + +import com.piuda.callcare.domain.ocrresult.dto.response.NaverOcrApiResponse; + +import java.util.List; + +/** + * Naver OCR 호출 결과. 파싱에 쓰는 {@code fields}와, 실패 재현·회귀 테스트용으로 + * 저장할 응답 원문 {@code rawResponseJson}을 함께 담는다. + */ +public record NaverOcrCallResult( + List fields, + String rawResponseJson +) {} From 7b6b6cd079fa971eab68c1e8d2ceb17c676ce7af Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Sun, 6 Sep 2026 15:32:50 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20=EC=A3=BC=EB=AF=BC=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EB=A7=88=EC=8A=A4=ED=82=B9=20=EC=9C=A0=ED=8B=B8=20?= =?UTF-8?q?PiiMasker=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OCR 결과 저장 전 주민등록번호(6자리-7자리, 뒷자리 첫 숫자 1~8)를 정규식으로 마스킹한다. 형식이 고정이라 오탐 위험이 낮다. 이름·생년월일은 형식이 없고 저장 허용 범위라 대상에서 제외한다. Co-Authored-By: Claude Sonnet 5 --- .../piuda/callcare/global/util/PiiMasker.java | 26 +++++++++ .../callcare/global/util/PiiMaskerTest.java | 53 +++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 src/main/java/com/piuda/callcare/global/util/PiiMasker.java create mode 100644 src/test/java/com/piuda/callcare/global/util/PiiMaskerTest.java diff --git a/src/main/java/com/piuda/callcare/global/util/PiiMasker.java b/src/main/java/com/piuda/callcare/global/util/PiiMasker.java new file mode 100644 index 0000000..668ffb8 --- /dev/null +++ b/src/main/java/com/piuda/callcare/global/util/PiiMasker.java @@ -0,0 +1,26 @@ +package com.piuda.callcare.global.util; + +import java.util.regex.Pattern; + +/** + * OCR 결과를 저장하기 전 민감정보를 가리는 유틸. + *

+ * 주민등록번호만 대상으로 한다. 형식이 고정("6자리-7자리", 뒷자리 첫 숫자 1~8)이라 + * 정규식으로 안전하게 잡힌다. 환자 이름·생년월일은 형식이 없어 자동 식별이 어렵고, + * 저장 허용 범위라 건드리지 않는다. + */ +public final class PiiMasker { + + private static final Pattern RESIDENT_NUMBER = Pattern.compile("\\d{6}\\s*-\\s*[1-8]\\d{6}"); + private static final String MASK = "******-*******"; + + private PiiMasker() { + } + + public static String maskResidentNumber(String text) { + if (text == null) { + return null; + } + return RESIDENT_NUMBER.matcher(text).replaceAll(MASK); + } +} diff --git a/src/test/java/com/piuda/callcare/global/util/PiiMaskerTest.java b/src/test/java/com/piuda/callcare/global/util/PiiMaskerTest.java new file mode 100644 index 0000000..3fe8ba2 --- /dev/null +++ b/src/test/java/com/piuda/callcare/global/util/PiiMaskerTest.java @@ -0,0 +1,53 @@ +package com.piuda.callcare.global.util; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("PiiMasker 단위 테스트") +class PiiMaskerTest { + + @ParameterizedTest + @ValueSource(strings = { + "환자 900101-1234567 님", + "주민등록번호: 900101 - 1234567" + }) + @DisplayName("주민번호(6자리-7자리, 공백 허용)는 마스킹된다") + void 주민번호_마스킹(String input) { + String masked = PiiMasker.maskResidentNumber(input); + + assertThat(masked).contains("******-*******"); + assertThat(masked).doesNotContain("1234567"); + } + + @Test + @DisplayName("하이픈 있는 주민번호만 마스킹하고 나머지 숫자는 건드리지 않는다") + void 하이픈_있는_주민번호만_마스킹() { + String input = "교부번호 20260701-00042 / 환자 900101-1234567 / 금액 14,940원"; + + String masked = PiiMasker.maskResidentNumber(input); + + assertThat(masked) + .contains("20260701-00042") // 교부번호는 뒷자리 8자리라 패턴 불일치 → 유지 + .contains("14,940원") + .contains("******-*******") + .doesNotContain("900101-1234567"); + } + + @Test + @DisplayName("뒷자리 첫 숫자가 1~8이 아니면 마스킹하지 않는다") + void 잘못된_뒷자리_미마스킹() { + String input = "코드 123456-9876543"; + + assertThat(PiiMasker.maskResidentNumber(input)).isEqualTo(input); + } + + @Test + @DisplayName("null은 null을 반환한다") + void null_처리() { + assertThat(PiiMasker.maskResidentNumber(null)).isNull(); + } +} From 6ffb662c3036615803909ca2bf0380abdb81a420 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Sun, 6 Sep 2026 15:33:06 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20NaverOcrClient=EA=B0=80=20OCR=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EC=9B=90=EB=AC=B8=EC=9D=84=20=ED=95=A8?= =?UTF-8?q?=EA=BB=98=20=EB=B0=98=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bodyToMono(String)으로 원문을 받아 ObjectMapper로 파싱하고, fields와 원문을 NaverOcrCallResult로 반환한다. 표 처방전은 필드가 많아 응답이 기본 코덱 한도(256KB)를 넘을 수 있어 이 클라이언트 한정으로 maxInMemorySize를 10MB로 올린다. Co-Authored-By: Claude Sonnet 5 --- .../ocrresult/client/NaverOcrClient.java | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/piuda/callcare/domain/ocrresult/client/NaverOcrClient.java b/src/main/java/com/piuda/callcare/domain/ocrresult/client/NaverOcrClient.java index 3941864..b53abeb 100644 --- a/src/main/java/com/piuda/callcare/domain/ocrresult/client/NaverOcrClient.java +++ b/src/main/java/com/piuda/callcare/domain/ocrresult/client/NaverOcrClient.java @@ -1,18 +1,19 @@ package com.piuda.callcare.domain.ocrresult.client; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.piuda.callcare.domain.ocrresult.dto.NaverOcrCallResult; import com.piuda.callcare.domain.ocrresult.dto.response.NaverOcrApiResponse; import com.piuda.callcare.global.exception.CallCareException; import com.piuda.callcare.global.exception.ErrorCode; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.ByteArrayResource; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; -import org.springframework.web.multipart.MultipartFile; import org.springframework.web.reactive.function.BodyInserters; import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClientResponseException; +import org.springframework.web.multipart.MultipartFile; import java.time.Duration; import java.util.List; @@ -21,10 +22,13 @@ @Slf4j @Component -@RequiredArgsConstructor public class NaverOcrClient { + // 표 처방전은 필드(텍스트+좌표)가 많아 응답이 기본 코덱 한도(256KB)를 넘을 수 있어 상향 + private static final int MAX_IN_MEMORY_SIZE = 10 * 1024 * 1024; + private final WebClient webClient; + private final ObjectMapper objectMapper; @Value("${naver.ocr.invoke-url}") private String invokeUrl; @@ -32,7 +36,14 @@ public class NaverOcrClient { @Value("${naver.ocr.secret-key}") private String secretKey; - public List callOcr(MultipartFile image) { + public NaverOcrClient(WebClient webClient, ObjectMapper objectMapper) { + this.webClient = webClient.mutate() + .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(MAX_IN_MEMORY_SIZE)) + .build(); + this.objectMapper = objectMapper; + } + + public NaverOcrCallResult callOcr(MultipartFile image) { try { String filename = Objects.requireNonNullElse(image.getOriginalFilename(), "image.jpg"); String format = extractFormat(filename); @@ -45,17 +56,18 @@ public String getFilename() { } }; - NaverOcrApiResponse response = webClient.post() + String rawResponseJson = webClient.post() .uri(invokeUrl) .header("X-OCR-SECRET", secretKey) .contentType(MediaType.MULTIPART_FORM_DATA) .body(BodyInserters.fromMultipartData("message", messageJson) .with("file", imageResource)) .retrieve() - .bodyToMono(NaverOcrApiResponse.class) + .bodyToMono(String.class) .block(Duration.ofSeconds(35)); - return extractFields(response); + NaverOcrApiResponse response = objectMapper.readValue(rawResponseJson, NaverOcrApiResponse.class); + return new NaverOcrCallResult(extractFields(response), rawResponseJson); } catch (WebClientResponseException e) { log.error("Naver OCR API 응답 오류 - status: {}, body: {}", e.getStatusCode(), e.getResponseBodyAsString()); From 4949e8dd2a201f607af3d4eb4be51a3b8eb5cfc5 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Sun, 6 Sep 2026 15:33:06 +0900 Subject: [PATCH 5/8] =?UTF-8?q?feat:=20OCR=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EC=9B=90=EB=AC=B8=20=EC=A0=80=EC=9E=A5=20+=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EC=A0=84=20=EC=A3=BC=EB=AF=BC=EB=B2=88=ED=98=B8=20?= =?UTF-8?q?=EB=A7=88=EC=8A=A4=ED=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NaverOcrCallResult를 받아 raw_text와 raw_response를 함께 저장하고, 둘 다 PiiMasker로 주민번호를 마스킹한 뒤 저장한다. 응답 DTO(OcrResultResponse)는 그대로 두어 프론트 영향이 없다. Co-Authored-By: Claude Sonnet 5 --- .../service/command/OcrCommandService.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 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 06b3ca0..5cf1b9f 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 @@ -2,9 +2,9 @@ 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.dto.response.NaverOcrApiResponse; import com.piuda.callcare.domain.ocrresult.dto.response.OcrResultResponse; import com.piuda.callcare.domain.ocrresult.entity.OcrResult; import com.piuda.callcare.domain.ocrresult.enums.OcrType; @@ -14,13 +14,12 @@ import com.piuda.callcare.domain.senior.repository.SeniorRepository; import com.piuda.callcare.global.exception.CallCareException; import com.piuda.callcare.global.exception.ErrorCode; +import com.piuda.callcare.global.util.PiiMasker; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; -import java.util.List; - @Slf4j @Service @RequiredArgsConstructor @@ -36,17 +35,18 @@ public OcrResultResponse processOcr(Long userId, Long seniorId, MultipartFile im Senior senior = seniorRepository.findByIdAndUser_Id(seniorId, userId) .orElseThrow(() -> new CallCareException(ErrorCode.SENIOR_NOT_FOUND)); - // OCR 호출 → fields(텍스트 + 좌표 블록 목록) 반환 - List fields = naverOcrClient.callOcr(image); + // OCR 호출 → fields(텍스트 + 좌표 블록 목록) + 응답 원문 반환 + NaverOcrCallResult ocrCallResult = naverOcrClient.callOcr(image); // 파싱: rawText 조립 + 약 정보 추출 (표 처방전이면 여러 약) - OcrParseResult parseResult = ocrParser.parse(fields, ocrType); + OcrParseResult parseResult = ocrParser.parse(ocrCallResult.fields(), ocrType); - // OcrResult DB 저장: rawText + 첫 번째 약 파싱 결과 + // OcrResult DB 저장: rawText + 응답 원문 + 첫 번째 약 파싱 결과. 주민번호는 저장 전 마스킹 OcrResult ocrResult = OcrResult.builder() .senior(senior) .ocrType(ocrType) - .rawText(parseResult.rawText()) + .rawText(PiiMasker.maskResidentNumber(parseResult.rawText())) + .rawResponse(PiiMasker.maskResidentNumber(ocrCallResult.rawResponseJson())) .build(); ParsedOcrData first = parseResult.parsedDrugs().isEmpty() From 2849961f6cf5a77e9b36612b9dc93aa30949b625 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Sun, 6 Sep 2026 15:33:06 +0900 Subject: [PATCH 6/8] =?UTF-8?q?test:=20OcrParser=20=ED=9A=8C=EA=B7=80=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=ED=95=98=EB=84=A4=EC=8A=A4=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 저장된 Naver 응답(fixture)을 OcrParser에 돌려 약 단위 precision/recall을 리포트하고, guard fixture는 결과가 어긋나면 실패시킨다. - fixture 2건: 실제 유성온누리약국 영수증(별표형, guard), 합성 표 처방전(좌표 포함, 이슈 B 대상) - 합성 표 처방전 헤더는 "처방 의약품의"+"명칭" 분리를 의도적으로 재현 - baseline: 영수증 R=1.00 / 표 처방전 R=0.00 (추출 1/5) Co-Authored-By: Claude Sonnet 5 --- .../ocrresult/fixture/OcrFixtureLoader.java | 59 ++ .../service/OcrParserRegressionTest.java | 94 ++ src/test/resources/ocr/expected/manifest.json | 29 + .../fixtures/pharmacy_receipt_yuseong.json | 707 +++++++++++++++ .../fixtures/table_prescription_synth.json | 806 ++++++++++++++++++ 5 files changed, 1695 insertions(+) create mode 100644 src/test/java/com/piuda/callcare/domain/ocrresult/fixture/OcrFixtureLoader.java create mode 100644 src/test/java/com/piuda/callcare/domain/ocrresult/service/OcrParserRegressionTest.java create mode 100644 src/test/resources/ocr/expected/manifest.json create mode 100644 src/test/resources/ocr/fixtures/pharmacy_receipt_yuseong.json create mode 100644 src/test/resources/ocr/fixtures/table_prescription_synth.json diff --git a/src/test/java/com/piuda/callcare/domain/ocrresult/fixture/OcrFixtureLoader.java b/src/test/java/com/piuda/callcare/domain/ocrresult/fixture/OcrFixtureLoader.java new file mode 100644 index 0000000..e27542f --- /dev/null +++ b/src/test/java/com/piuda/callcare/domain/ocrresult/fixture/OcrFixtureLoader.java @@ -0,0 +1,59 @@ +package com.piuda.callcare.domain.ocrresult.fixture; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.piuda.callcare.domain.ocrresult.dto.ParsedOcrData; +import com.piuda.callcare.domain.ocrresult.dto.response.NaverOcrApiResponse; + +import java.io.InputStream; +import java.util.List; + +/** + * 저장된 Naver OCR 응답(fixture)과 기대 약 목록(manifest)을 읽어 테스트에 넘긴다. + * fixture JSON은 실제 응답과 동일한 형태({@link NaverOcrApiResponse})다. + */ +public final class OcrFixtureLoader { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final String FIXTURE_DIR = "/ocr/fixtures/"; + private static final String MANIFEST_PATH = "/ocr/expected/manifest.json"; + + private OcrFixtureLoader() { + } + + public static List loadFields(String fixtureFile) { + try (InputStream in = open(FIXTURE_DIR + fixtureFile)) { + NaverOcrApiResponse response = MAPPER.readValue(in, NaverOcrApiResponse.class); + return response.images().get(0).fields(); + } catch (Exception e) { + throw new IllegalStateException("fixture 로드 실패: " + fixtureFile, e); + } + } + + public static Manifest loadManifest() { + try (InputStream in = open(MANIFEST_PATH)) { + return MAPPER.readValue(in, Manifest.class); + } catch (Exception e) { + throw new IllegalStateException("manifest 로드 실패", e); + } + } + + private static InputStream open(String path) { + InputStream in = OcrFixtureLoader.class.getResourceAsStream(path); + if (in == null) { + throw new IllegalStateException("리소스 없음: " + path); + } + return in; + } + + public record Manifest(List fixtures) { + } + + public record FixtureCase( + String file, + String ocrType, + boolean guard, + String note, + List expected + ) { + } +} diff --git a/src/test/java/com/piuda/callcare/domain/ocrresult/service/OcrParserRegressionTest.java b/src/test/java/com/piuda/callcare/domain/ocrresult/service/OcrParserRegressionTest.java new file mode 100644 index 0000000..38b689b --- /dev/null +++ b/src/test/java/com/piuda/callcare/domain/ocrresult/service/OcrParserRegressionTest.java @@ -0,0 +1,94 @@ +package com.piuda.callcare.domain.ocrresult.service; + +import com.piuda.callcare.domain.ocrresult.dto.OcrParseResult; +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.DynamicTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; + +import java.util.List; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 저장된 실제/합성 OCR 응답을 {@link OcrParser}에 돌려 회귀를 잡는다. + *

    + *
  • guard=true fixture: 현재 정상 동작 → 결과가 어긋나면 실패 (회귀 가드)
  • + *
  • guard=false fixture: 아직 미달 → precision/recall만 리포트, 실패시키지 않음 + * (해당 이슈에서 fix + guard 승격)
  • + *
+ */ +@DisplayName("OcrParser 회귀 테스트") +class OcrParserRegressionTest { + + private final OcrParser ocrParser = new OcrParser(); + + @TestFactory + @DisplayName("fixture별 precision/recall 리포트 + 회귀 가드") + Stream 회귀_리포트() { + List cases = OcrFixtureLoader.loadManifest().fixtures(); + return cases.stream().map(fc -> DynamicTest.dynamicTest(fc.file(), () -> { + List fields = OcrFixtureLoader.loadFields(fc.file()); + OcrParseResult result = ocrParser.parse(fields, OcrType.valueOf(fc.ocrType())); + List actual = result.parsedDrugs(); + + Score score = score(fc.expected(), actual); + System.out.printf( + "[%s] exact P=%.2f R=%.2f | name R=%.2f | 기대 %d, 추출 %d, 정확일치 %d%n ↳ %s%n", + fc.file(), score.precision(), score.recall(), score.nameRecall(), + fc.expected().size(), actual.size(), score.exactMatches(), fc.note()); + + if (fc.guard()) { + assertThat(actual) + .as("guard fixture는 기대 약 목록과 정확히 일치해야 한다: %s", fc.file()) + .containsExactlyInAnyOrderElementsOf(fc.expected()); + } + })); + } + + @Test + @DisplayName("정상 케이스: 유성온누리약국 영수증 - 약 4건, 병원명 '중앙정'은 약으로 잡히지 않는다") + void 유성온누리_영수증_약4건_병원명오탐없음() { + // Given + List fields = OcrFixtureLoader.loadFields("pharmacy_receipt_yuseong.json"); + + // When + List drugs = ocrParser.parse(fields, OcrType.PRESCRIPTION).parsedDrugs(); + + // Then + assertThat(drugs).hasSize(4); + assertThat(drugs).extracting(ParsedOcrData::drugName) + .containsExactly("아클펜정", "아트놀셋세미정", "모사피트정", "에페신정") + .doesNotContain("중앙정"); + assertThat(drugs).allSatisfy(d -> { + assertThat(d.dosagePerTime()).isEqualTo("1정"); + assertThat(d.timesPerDay()).isEqualTo(2); + assertThat(d.totalDays()).isEqualTo(5); + }); + } + + // 기대 약 목록 대비 파싱 결과 채점. exact = 4개 필드 완전 일치, name = 약 이름만 일치 + private Score score(List expected, List actual) { + long exact = expected.stream().filter(actual::contains).count(); + long nameHit = expected.stream() + .filter(e -> actual.stream().anyMatch(a -> equalsIgnoreNull(e.drugName(), a.drugName()))) + .count(); + double precision = actual.isEmpty() ? 0.0 : (double) exact / actual.size(); + double recall = expected.isEmpty() ? 0.0 : (double) exact / expected.size(); + double nameRecall = expected.isEmpty() ? 0.0 : (double) nameHit / expected.size(); + return new Score(precision, recall, nameRecall, exact); + } + + private boolean equalsIgnoreNull(String a, String b) { + return a != null && a.equals(b); + } + + private record Score(double precision, double recall, double nameRecall, long exactMatches) { + } +} diff --git a/src/test/resources/ocr/expected/manifest.json b/src/test/resources/ocr/expected/manifest.json new file mode 100644 index 0000000..8fc9b9d --- /dev/null +++ b/src/test/resources/ocr/expected/manifest.json @@ -0,0 +1,29 @@ +{ + "fixtures": [ + { + "file": "pharmacy_receipt_yuseong.json", + "ocrType": "PRESCRIPTION", + "guard": true, + "note": "유성온누리약국 영수증 (별표형, 좌표 없음) - 현재 정상 동작, 회귀 가드", + "expected": [ + { "drugName": "아클펜정", "dosagePerTime": "1정", "timesPerDay": 2, "totalDays": 5 }, + { "drugName": "아트놀셋세미정", "dosagePerTime": "1정", "timesPerDay": 2, "totalDays": 5 }, + { "drugName": "모사피트정", "dosagePerTime": "1정", "timesPerDay": 2, "totalDays": 5 }, + { "drugName": "에페신정", "dosagePerTime": "1정", "timesPerDay": 2, "totalDays": 5 } + ] + }, + { + "file": "table_prescription_synth.json", + "ocrType": "PRESCRIPTION", + "guard": false, + "note": "합성 표 처방전 (좌표 포함) - 이슈 B 대상, 현재 미달", + "expected": [ + { "drugName": "아모잘탄정", "dosagePerTime": "1정", "timesPerDay": 1, "totalDays": 30 }, + { "drugName": "크레스토정", "dosagePerTime": "1정", "timesPerDay": 1, "totalDays": 30 }, + { "drugName": "리피토정", "dosagePerTime": "1정", "timesPerDay": 1, "totalDays": 90 }, + { "drugName": "노바스크정", "dosagePerTime": "1정", "timesPerDay": 2, "totalDays": 28 }, + { "drugName": "아스피린프로텍트정", "dosagePerTime": "1정", "timesPerDay": 1, "totalDays": 30 } + ] + } + ] +} diff --git a/src/test/resources/ocr/fixtures/pharmacy_receipt_yuseong.json b/src/test/resources/ocr/fixtures/pharmacy_receipt_yuseong.json new file mode 100644 index 0000000..58163da --- /dev/null +++ b/src/test/resources/ocr/fixtures/pharmacy_receipt_yuseong.json @@ -0,0 +1,707 @@ +{ + "version": "V2", + "requestId": "fixture-yuseong", + "timestamp": 0, + "images": [ + { + "uid": "u1", + "name": "image", + "inferResult": "SUCCESS", + "fields": [ + { + "inferText": "약제비 계산서 영수증 [별지제 11호 서식]", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "조제약&복약안내", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "조제약사: 박도현", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "조제일자: 2026-06-27", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "환 자 성 명: 강철웅(만 24세/남)", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "처방전교부번호: 20260627-01019", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "처방전발행기관: 중앙정형외과의원", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "영 수 증 번 호", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "(연월- 일련번호)", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "환 자 성 명", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "투 약 일 수", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "조 제 일 자.", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "2026-06-27", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "20260627-0047", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "강철웅", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "5", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "약품명", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "약품사진", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "복약안내 (투약량/횟수/일수)", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "복약만료일 2026", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "야간(공휴일)조제", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "야간( )공휴일( )", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "*아클펜정(아세클로페낙)_(..", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "1회투약량1", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "1일투여횟수2", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "총투약일수5", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "약제비총액(①+②+③)", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "14,940 원", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "흰색 정제", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "실온보관", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "*아트놀셋세미정_(1정)", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "튀김강력기", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "나타날 수 있어요", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "1회투약량 1", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "하는 약", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "1일투여횟수2", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "총투약일수5", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "소염진통제", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "위장장애가 나타날 수 있어요. 증상이 심하면 전문가와 상의하세요.", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "본인부담금①", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "보험자부담금②", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "비급여및전액본인부담금③", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "10,540 원", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "4,400원", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "0 원", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "0 원", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "노랑색 정제", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "실온보관", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "따르한 물과", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "Result", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "추가의 수 작", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "로 진 또는 타입한 시", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "진통제", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "충분한 물과 함께 투여하세요.", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "카 드", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "현금영수증", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "0 원", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "총수납금액", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "(①+③)", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "현 금", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "4,400 원", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "*모사피트정5밀리그램(모사프..", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "1회투약량 1", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "흰색 정제", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "실온보관", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "매콤한 서울특별시", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "DAIL 048", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "먹는 약", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "1일투여횟수2", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "총투약일수5", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "위장운동촉진제", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "식사와 관계없이 투여해도 괜찮아요.", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "현금영수증", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "합 계", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "신분확인번호", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "4,400 원", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "[ ]", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "에페신정(에페리손염산염)(..", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "흰색 정제", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "실온보관", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "순한보는하르는 수정조각 주차지", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "1회투약량 1", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "F29", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "1일투여횟수2", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "총투약일수5", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "근이완제", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "졸음이 올 수 있으므로 운전, 위험한 기계조작시 주의하세요.", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "현금승인번호", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "사업자등록번호", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "101-13-65757", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "사업장소재지", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "경기도 시흥시 도일로", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "124번길 2, 102호", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "상 호", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "유성온누리약국", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "성 명", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "김미희", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "발 행 일", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + }, + { + "inferText": "2026-06-27", + "lineBreak": true, + "boundingPoly": { + "vertices": [] + } + } + ] + } + ] +} \ No newline at end of file diff --git a/src/test/resources/ocr/fixtures/table_prescription_synth.json b/src/test/resources/ocr/fixtures/table_prescription_synth.json new file mode 100644 index 0000000..6b8ec11 --- /dev/null +++ b/src/test/resources/ocr/fixtures/table_prescription_synth.json @@ -0,0 +1,806 @@ +{ + "version": "V2", + "requestId": "fixture-table-synth", + "timestamp": 0, + "images": [ + { + "uid": "u1", + "name": "image", + "inferResult": "SUCCESS", + "fields": [ + { + "inferText": "처방전", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 90.0, + "y": 19.0 + }, + { + "x": 150.0, + "y": 19.0 + }, + { + "x": 150.0, + "y": 41.0 + }, + { + "x": 90.0, + "y": 41.0 + } + ] + } + }, + { + "inferText": "교부번호 20260701-00042", + "lineBreak": true, + "boundingPoly": { + "vertices": [ + { + "x": 250.0, + "y": 19.0 + }, + { + "x": 550.0, + "y": 19.0 + }, + { + "x": 550.0, + "y": 41.0 + }, + { + "x": 250.0, + "y": 41.0 + } + ] + } + }, + { + "inferText": "처방 의약품의", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 55.0, + "y": 79.0 + }, + { + "x": 205.0, + "y": 79.0 + }, + { + "x": 205.0, + "y": 101.0 + }, + { + "x": 55.0, + "y": 101.0 + } + ] + } + }, + { + "inferText": "명칭", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 220.0, + "y": 79.0 + }, + { + "x": 280.0, + "y": 79.0 + }, + { + "x": 280.0, + "y": 101.0 + }, + { + "x": 220.0, + "y": 101.0 + } + ] + } + }, + { + "inferText": "1회 투약량", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 375.0, + "y": 79.0 + }, + { + "x": 485.0, + "y": 79.0 + }, + { + "x": 485.0, + "y": 101.0 + }, + { + "x": 375.0, + "y": 101.0 + } + ] + } + }, + { + "inferText": "1일 투여횟수", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 515.0, + "y": 79.0 + }, + { + "x": 645.0, + "y": 79.0 + }, + { + "x": 645.0, + "y": 101.0 + }, + { + "x": 515.0, + "y": 101.0 + } + ] + } + }, + { + "inferText": "총 투약일수", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 660.0, + "y": 79.0 + }, + { + "x": 780.0, + "y": 79.0 + }, + { + "x": 780.0, + "y": 101.0 + }, + { + "x": 660.0, + "y": 101.0 + } + ] + } + }, + { + "inferText": "용법", + "lineBreak": true, + "boundingPoly": { + "vertices": [ + { + "x": 820.0, + "y": 79.0 + }, + { + "x": 880.0, + "y": 79.0 + }, + { + "x": 880.0, + "y": 101.0 + }, + { + "x": 820.0, + "y": 101.0 + } + ] + } + }, + { + "inferText": "[급여][643501510]아모잘탄정5/50밀리그램", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 10.0, + "y": 129.0 + }, + { + "x": 350.0, + "y": 129.0 + }, + { + "x": 350.0, + "y": 151.0 + }, + { + "x": 10.0, + "y": 151.0 + } + ] + } + }, + { + "inferText": "1", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 415.0, + "y": 129.0 + }, + { + "x": 445.0, + "y": 129.0 + }, + { + "x": 445.0, + "y": 151.0 + }, + { + "x": 415.0, + "y": 151.0 + } + ] + } + }, + { + "inferText": "1", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 565.0, + "y": 129.0 + }, + { + "x": 595.0, + "y": 129.0 + }, + { + "x": 595.0, + "y": 151.0 + }, + { + "x": 565.0, + "y": 151.0 + } + ] + } + }, + { + "inferText": "30", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 700.0, + "y": 129.0 + }, + { + "x": 740.0, + "y": 129.0 + }, + { + "x": 740.0, + "y": 151.0 + }, + { + "x": 700.0, + "y": 151.0 + } + ] + } + }, + { + "inferText": "1일 1회 아침 식후 30분", + "lineBreak": true, + "boundingPoly": { + "vertices": [ + { + "x": 760.0, + "y": 129.0 + }, + { + "x": 960.0, + "y": 129.0 + }, + { + "x": 960.0, + "y": 151.0 + }, + { + "x": 760.0, + "y": 151.0 + } + ] + } + }, + { + "inferText": "[급여][123456780]크레스토정10밀리그램", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 10.0, + "y": 174.0 + }, + { + "x": 350.0, + "y": 174.0 + }, + { + "x": 350.0, + "y": 196.0 + }, + { + "x": 10.0, + "y": 196.0 + } + ] + } + }, + { + "inferText": "1", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 415.0, + "y": 174.0 + }, + { + "x": 445.0, + "y": 174.0 + }, + { + "x": 445.0, + "y": 196.0 + }, + { + "x": 415.0, + "y": 196.0 + } + ] + } + }, + { + "inferText": "1", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 565.0, + "y": 174.0 + }, + { + "x": 595.0, + "y": 174.0 + }, + { + "x": 595.0, + "y": 196.0 + }, + { + "x": 565.0, + "y": 196.0 + } + ] + } + }, + { + "inferText": "30", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 700.0, + "y": 174.0 + }, + { + "x": 740.0, + "y": 174.0 + }, + { + "x": 740.0, + "y": 196.0 + }, + { + "x": 700.0, + "y": 196.0 + } + ] + } + }, + { + "inferText": "1일 1회 아침 식후 30분", + "lineBreak": true, + "boundingPoly": { + "vertices": [ + { + "x": 760.0, + "y": 174.0 + }, + { + "x": 960.0, + "y": 174.0 + }, + { + "x": 960.0, + "y": 196.0 + }, + { + "x": 760.0, + "y": 196.0 + } + ] + } + }, + { + "inferText": "[급여][222333440]리피토정20밀리그램", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 10.0, + "y": 219.0 + }, + { + "x": 350.0, + "y": 219.0 + }, + { + "x": 350.0, + "y": 241.0 + }, + { + "x": 10.0, + "y": 241.0 + } + ] + } + }, + { + "inferText": "1", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 415.0, + "y": 219.0 + }, + { + "x": 445.0, + "y": 219.0 + }, + { + "x": 445.0, + "y": 241.0 + }, + { + "x": 415.0, + "y": 241.0 + } + ] + } + }, + { + "inferText": "1", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 565.0, + "y": 219.0 + }, + { + "x": 595.0, + "y": 219.0 + }, + { + "x": 595.0, + "y": 241.0 + }, + { + "x": 565.0, + "y": 241.0 + } + ] + } + }, + { + "inferText": "90", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 700.0, + "y": 219.0 + }, + { + "x": 740.0, + "y": 219.0 + }, + { + "x": 740.0, + "y": 241.0 + }, + { + "x": 700.0, + "y": 241.0 + } + ] + } + }, + { + "inferText": "1일 1회 아침 식후 30분", + "lineBreak": true, + "boundingPoly": { + "vertices": [ + { + "x": 760.0, + "y": 219.0 + }, + { + "x": 960.0, + "y": 219.0 + }, + { + "x": 960.0, + "y": 241.0 + }, + { + "x": 760.0, + "y": 241.0 + } + ] + } + }, + { + "inferText": "[급여][555666770]노바스크정5밀리그램", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 10.0, + "y": 264.0 + }, + { + "x": 350.0, + "y": 264.0 + }, + { + "x": 350.0, + "y": 286.0 + }, + { + "x": 10.0, + "y": 286.0 + } + ] + } + }, + { + "inferText": "1", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 415.0, + "y": 264.0 + }, + { + "x": 445.0, + "y": 264.0 + }, + { + "x": 445.0, + "y": 286.0 + }, + { + "x": 415.0, + "y": 286.0 + } + ] + } + }, + { + "inferText": "2", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 565.0, + "y": 264.0 + }, + { + "x": 595.0, + "y": 264.0 + }, + { + "x": 595.0, + "y": 286.0 + }, + { + "x": 565.0, + "y": 286.0 + } + ] + } + }, + { + "inferText": "28", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 700.0, + "y": 264.0 + }, + { + "x": 740.0, + "y": 264.0 + }, + { + "x": 740.0, + "y": 286.0 + }, + { + "x": 700.0, + "y": 286.0 + } + ] + } + }, + { + "inferText": "1일 1회 아침 식후 30분", + "lineBreak": true, + "boundingPoly": { + "vertices": [ + { + "x": 760.0, + "y": 264.0 + }, + { + "x": 960.0, + "y": 264.0 + }, + { + "x": 960.0, + "y": 286.0 + }, + { + "x": 760.0, + "y": 286.0 + } + ] + } + }, + { + "inferText": "[급여][888999000]아스피린프로텍트정100밀리그램", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 10.0, + "y": 309.0 + }, + { + "x": 350.0, + "y": 309.0 + }, + { + "x": 350.0, + "y": 331.0 + }, + { + "x": 10.0, + "y": 331.0 + } + ] + } + }, + { + "inferText": "1", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 415.0, + "y": 309.0 + }, + { + "x": 445.0, + "y": 309.0 + }, + { + "x": 445.0, + "y": 331.0 + }, + { + "x": 415.0, + "y": 331.0 + } + ] + } + }, + { + "inferText": "1", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 565.0, + "y": 309.0 + }, + { + "x": 595.0, + "y": 309.0 + }, + { + "x": 595.0, + "y": 331.0 + }, + { + "x": 565.0, + "y": 331.0 + } + ] + } + }, + { + "inferText": "30", + "lineBreak": false, + "boundingPoly": { + "vertices": [ + { + "x": 700.0, + "y": 309.0 + }, + { + "x": 740.0, + "y": 309.0 + }, + { + "x": 740.0, + "y": 331.0 + }, + { + "x": 700.0, + "y": 331.0 + } + ] + } + }, + { + "inferText": "1일 1회 아침 식후 30분", + "lineBreak": true, + "boundingPoly": { + "vertices": [ + { + "x": 760.0, + "y": 309.0 + }, + { + "x": 960.0, + "y": 309.0 + }, + { + "x": 960.0, + "y": 331.0 + }, + { + "x": 760.0, + "y": 331.0 + } + ] + } + } + ] + } + ] +} \ No newline at end of file From 739cba1b02b1af2b1e29701c043df97600321939 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Sun, 6 Sep 2026 15:33:06 +0900 Subject: [PATCH 7/8] =?UTF-8?q?docs:=20=EC=9D=B4=EC=8A=88=2096=20OCR=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EC=A0=80=EC=9E=A5=C2=B7=ED=9A=8C=EA=B7=80?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B8=B0=EB=B0=98=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 배경, 변경 내용, baseline 측정, 변경 파일, 수동 검증 절차, 후속 이슈를 정리한다. Co-Authored-By: Claude Sonnet 5 --- .../issue-96-ocr-response-storage.md | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 docs/kangcheolung/issue-96-ocr-response-storage.md diff --git a/docs/kangcheolung/issue-96-ocr-response-storage.md b/docs/kangcheolung/issue-96-ocr-response-storage.md new file mode 100644 index 0000000..a02b1a5 --- /dev/null +++ b/docs/kangcheolung/issue-96-ocr-response-storage.md @@ -0,0 +1,149 @@ +# 이슈 #96 — OCR 응답 원본 저장 + 파서 회귀 테스트 기반 + +> 브랜치: `feature/96` · 관련 이슈: [PIUDAProject/Backend#96](https://github.com/PIUDAProject/Backend/issues/96) +> +> OCR 파싱 개선 1단계의 선행 작업. 후속: #B(표 처방전 좌표 파싱), #C(약봉투 다중 약) + +--- + +## 1. 배경 + +약봉투·처방전 OCR 추출이 여러 서식에서 깨지는데, **실패를 재현할 수단이 없었다.** + +| 문제 | 상세 | +|---|---| +| 응답 원본 유실 | `NaverOcrClient`가 응답을 `NaverOcrApiResponse`로 파싱한 뒤 원본을 버림. `OcrResult`엔 텍스트를 이어붙인 `raw_text`만 남고 좌표(`boundingPoly`)는 사라짐 | +| 테스트 0개 | `OcrParser` 단위 테스트가 없어, 서식 하나를 고치면 다른 서식이 회귀했는지 알 수 없음 | +| 개인정보 | `raw_text`에 환자 주민번호가 마스킹 없이 저장됨 | + +이 이슈는 파싱 로직을 고치지 않는다. **회귀 테스트 기반**만 만든다. 응답 DTO(`OcrResultResponse`) +외부 계약은 그대로 두어 프론트 영향이 없다. + +--- + +## 2. 변경 + +### 2-1. Naver 응답 원본 저장 + +`NaverOcrClient.callOcr()` — `bodyToMono(String.class)`로 원문을 받아 주입한 `ObjectMapper`로 파싱. +반환 타입을 `List` → `NaverOcrCallResult(fields, rawResponseJson)`로 변경. + +```text +webClient.post()...bodyToMono(String.class) ← 원문 문자열 + → objectMapper.readValue(raw, NaverOcrApiResponse.class) + → new NaverOcrCallResult(extractFields(response), raw) +``` + +- WebClient 코덱 한도를 256KB(기본) → 10MB로 상향. 표 처방전은 필드(텍스트+좌표 4점)가 수백 개라 + 기본 한도를 넘을 수 있음. `webClient.mutate().codecs(...)`로 이 클라이언트만 조정. +- `extractFields()` 검증(`inferResult != SUCCESS` → `OCR_API_ERROR`)은 그대로. +- 응답 원문을 재직렬화가 아니라 **문자열 그대로** 저장하는 이유: Naver의 `inferConfidence` 등 + 우리 DTO에 없는 필드까지 보존해 회귀 코퍼스의 충실도를 유지. + +`OcrResult` — `raw_response` TEXT 컬럼 + 빌더 파라미터. `ddl-auto: update`라 마이그레이션 파일 불필요. + +### 2-2. 주민번호 마스킹 + +신규 `global/util/PiiMasker` — 주민번호만 대상. + +```java +// 6자리 - 7자리, 뒷자리 첫 숫자 1~8 (공백 허용) +Pattern.compile("\\d{6}\\s*-\\s*[1-8]\\d{6}") → "******-*******" +``` + +- 형식이 고정이라 정규식으로 안전하게 잡힌다. 교부번호(`20260701-00042`, 뒤 5자리)는 패턴 불일치라 유지. +- 이름·생년월일은 형식이 없어 자동 식별이 어렵고, 저장 허용 범위(팀 합의)라 건드리지 않는다. +- `OcrCommandService`에서 `raw_text`·`raw_response` 둘 다 저장 직전에 통과. + +### 2-3. 파서 회귀 테스트 하네스 + +| 파일 | 역할 | +|---|---| +| `test/resources/ocr/fixtures/*.json` | 저장된 Naver 응답(`NaverOcrApiResponse` 형태) | +| `test/resources/ocr/expected/manifest.json` | fixture → 기대 약 목록(`ParsedOcrData`) + `guard` 플래그 | +| `OcrFixtureLoader` | fixture/manifest 로드 | +| `OcrParserRegressionTest` | fixture별 `parse()` → precision/recall 리포트 + `guard` 단언 | + +- `new OcrParser()` — 의존성이 없어 Mockito 불필요 +- `guard=true`: 현재 정상 동작 → 결과가 어긋나면 **실패** (회귀 가드) +- `guard=false`: 아직 미달 → precision/recall만 **리포트**, 실패시키지 않음. 해당 이슈에서 fix + guard 승격 + +**fixture 2건 (초기)** + +| 파일 | 출처 | 좌표 | 용도 | +|---|---|---|---| +| `pharmacy_receipt_yuseong.json` | 실제 유성온누리약국 영수증 (별표형) | 없음 | 현재 정상 — 회귀 가드 | +| `table_prescription_synth.json` | 합성 표 처방전. 헤더 `처방 의약품의`+`명칭` 분리를 의도적으로 재현 | 있음 | 이슈 B 대상 | + +실제 약봉투·처방전 fixture는 팀이 사진에서 뽑아 이름 마스킹 후 추가한다. + +--- + +## 3. 측정 (baseline) + +``` +[pharmacy_receipt_yuseong.json] exact P=1.00 R=1.00 | name R=1.00 | 기대 4, 추출 4, 정확일치 4 + ↳ 유성온누리약국 영수증 (별표형, 좌표 없음) - 현재 정상 동작, 회귀 가드 +[table_prescription_synth.json] exact P=0.00 R=0.00 | name R=0.20 | 기대 5, 추출 1, 정확일치 0 + ↳ 합성 표 처방전 (좌표 포함) - 이슈 B 대상, 현재 미달 +``` + +- `exact` = 4개 필드(이름/1회량/1일횟수/총일수) 완전 일치, `name` = 이름만 일치 +- 표 처방전이 현재 얼마나 안 되는지가 수치로 고정됨 → **이슈 B가 R=1.00으로 뒤집는 게 목표** +- `OcrParserRegressionTest` 3 tests, 0 failures / `PiiMaskerTest` 통과 + +```bash +./gradlew test --tests "*OcrParserRegressionTest" --tests "*PiiMaskerTest" +``` + +--- + +## 4. 변경 파일 + +### 신규 + +| 파일 | 역할 | +|---|---| +| `domain/ocrresult/dto/NaverOcrCallResult` | OCR 호출 결과 — 파싱용 `fields` + 저장용 응답 원문 | +| `global/util/PiiMasker` | 주민번호 마스킹 | +| `test/.../ocrresult/fixture/OcrFixtureLoader` | fixture/manifest 로드 | +| `test/.../ocrresult/service/OcrParserRegressionTest` | 회귀 리포트 + 가드 | +| `test/.../global/util/PiiMaskerTest` | 마스킹 단위 테스트 | +| `test/resources/ocr/**` | fixture 2건 + manifest | + +### 수정 + +| 파일 | 변경 | +|---|---| +| `NaverOcrClient` | 응답 원문 수신·파싱·반환, 코덱 한도 상향, 명시적 생성자 | +| `OcrResult` | `raw_response` 컬럼 + 빌더 | +| `OcrCommandService` | `NaverOcrCallResult` 반영, 저장 전 주민번호 마스킹 | + +파싱 로직(`OcrParser`)·응답 DTO·보안 설정은 건드리지 않음. + +--- + +## 5. 수동 검증 (로컬) + +> 현재 `OcrController`·`MedicationController`에 로컬 테스트용 `userId` 하드코딩이 있음. +> **PR 전 `git checkout --`로 제거** (커밋 금지). + +```bash +./gradlew bootRun --args='--spring.profiles.active=local' +``` + +`POST /api/ocr` (form-data: `seniorId=1`, `image=<처방전 사진>`, `ocrType=PRESCRIPTION`) + +- [ ] `ocr_result` 새 행의 `raw_response`에 좌표 포함 응답 JSON 저장 +- [ ] 주민번호 있는 처방전 → `raw_text`·`raw_response` 모두 `******-*******` +- [ ] `OcrResultResponse` 필드 형태 변화 없음 + +--- + +## 6. 후속 + +- **이슈 B**: 표 처방전 좌표 파싱 복구 — 게이트 정규식 `\s*`→`[ \t]*`, `parseByCoordinates` + 헤더 x좌표 앵커 배정, `table_prescription_synth.json` guard 승격 +- **이슈 C**: 약봉투 압축형(`약이름\n1정씩1회5일분` 반복) 다중 약 +- **이슈 D**(범위 밖): ES/DrugInfo로 약 이름 검증 +- fixture 코퍼스 확장: 실제 약봉투·처방전 사진 (이름 마스킹) From a6f932e2fc256e3482e3afe8c77c8ce693336053 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Sun, 6 Sep 2026 16:15:51 +0900 Subject: [PATCH 8/8] =?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=20raw=5Fresponse=20=ED=83=80?= =?UTF-8?q?=EC=9E=85,=20=EC=A3=BC=EB=AF=BC=EB=B2=88=ED=98=B8=20=EC=A0=95?= =?UTF-8?q?=EA=B7=9C=EC=8B=9D,=20fixture=20=EC=9D=B5=EB=AA=85=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OcrResult.raw_response TEXT → MEDIUMTEXT: TEXT(64KB)는 좌표 포함 표 처방전 응답(WebClient 한도 10MB)을 담지 못해 저장 실패 가능 - PiiMasker 정규식: 하이픈 선택적(-?)으로 OCR이 놓친 주민번호도 마스킹, (? --- .../issue-96-ocr-response-storage.md | 14 ++++---- .../ocrresult/client/NaverOcrClient.java | 7 ++++ .../domain/ocrresult/entity/OcrResult.java | 3 +- .../piuda/callcare/global/util/PiiMasker.java | 16 +++++++--- .../ocrresult/fixture/OcrFixtureLoader.java | 2 ++ .../service/OcrParserRegressionTest.java | 8 ++--- .../callcare/global/util/PiiMaskerTest.java | 21 +++++++++--- src/test/resources/ocr/expected/manifest.json | 4 +-- ...ong.json => pharmacy_receipt_starred.json} | 32 +++++++++---------- 9 files changed, 69 insertions(+), 38 deletions(-) rename src/test/resources/ocr/fixtures/{pharmacy_receipt_yuseong.json => pharmacy_receipt_starred.json} (95%) diff --git a/docs/kangcheolung/issue-96-ocr-response-storage.md b/docs/kangcheolung/issue-96-ocr-response-storage.md index a02b1a5..4fb58de 100644 --- a/docs/kangcheolung/issue-96-ocr-response-storage.md +++ b/docs/kangcheolung/issue-96-ocr-response-storage.md @@ -40,17 +40,19 @@ webClient.post()...bodyToMono(String.class) ← 원문 문자열 - 응답 원문을 재직렬화가 아니라 **문자열 그대로** 저장하는 이유: Naver의 `inferConfidence` 등 우리 DTO에 없는 필드까지 보존해 회귀 코퍼스의 충실도를 유지. -`OcrResult` — `raw_response` TEXT 컬럼 + 빌더 파라미터. `ddl-auto: update`라 마이그레이션 파일 불필요. +`OcrResult` — `raw_response` `MEDIUMTEXT` 컬럼 + 빌더 파라미터. `ddl-auto: update`라 마이그레이션 파일 불필요. +MySQL `TEXT`는 64KB라, 좌표가 붙는 표 처방전 응답(WebClient 한도 10MB)을 담으려면 `MEDIUMTEXT`(16MB)가 필요하다. ### 2-2. 주민번호 마스킹 신규 `global/util/PiiMasker` — 주민번호만 대상. ```java -// 6자리 - 7자리, 뒷자리 첫 숫자 1~8 (공백 허용) -Pattern.compile("\\d{6}\\s*-\\s*[1-8]\\d{6}") → "******-*******" +// 6자리 [-] 7자리, 뒷자리 첫 숫자 1~8. 하이픈 선택적, 앞뒤 숫자 경계로 부분 일치 방지 +Pattern.compile("(? - * 주민등록번호만 대상으로 한다. 형식이 고정("6자리-7자리", 뒷자리 첫 숫자 1~8)이라 - * 정규식으로 안전하게 잡힌다. 환자 이름·생년월일은 형식이 없어 자동 식별이 어렵고, - * 저장 허용 범위라 건드리지 않는다. + * 주민등록번호만 대상으로 한다. 형식이 고정("6자리 [-] 7자리", 뒷자리 첫 숫자 1~8)이라 + * 정규식으로 안전하게 잡힌다. 하이픈은 OCR이 놓치는 경우가 있어 선택적으로 두고, + * 앞뒤 숫자 경계(lookbehind/lookahead)로 더 긴 숫자열 내부 부분 일치를 막는다. + * 환자 이름·생년월일은 형식이 없어 자동 식별이 어렵고, 저장 허용 범위라 건드리지 않는다. */ public final class PiiMasker { - private static final Pattern RESIDENT_NUMBER = Pattern.compile("\\d{6}\\s*-\\s*[1-8]\\d{6}"); + private static final Pattern RESIDENT_NUMBER = + Pattern.compile("(? loadFields(String fixtureFile) { try (InputStream in = open(FIXTURE_DIR + fixtureFile)) { NaverOcrApiResponse response = MAPPER.readValue(in, NaverOcrApiResponse.class); @@ -29,6 +30,7 @@ public static List loadFields(String fixtureFile) { } } + /** fixture 파일명 → 기대 약 목록 매핑(manifest.json)을 읽는다. */ public static Manifest loadManifest() { try (InputStream in = open(MANIFEST_PATH)) { return MAPPER.readValue(in, Manifest.class); diff --git a/src/test/java/com/piuda/callcare/domain/ocrresult/service/OcrParserRegressionTest.java b/src/test/java/com/piuda/callcare/domain/ocrresult/service/OcrParserRegressionTest.java index 38b689b..ce2d89d 100644 --- a/src/test/java/com/piuda/callcare/domain/ocrresult/service/OcrParserRegressionTest.java +++ b/src/test/java/com/piuda/callcare/domain/ocrresult/service/OcrParserRegressionTest.java @@ -53,10 +53,10 @@ class OcrParserRegressionTest { } @Test - @DisplayName("정상 케이스: 유성온누리약국 영수증 - 약 4건, 병원명 '중앙정'은 약으로 잡히지 않는다") - void 유성온누리_영수증_약4건_병원명오탐없음() { + @DisplayName("정상 케이스: 별표형 영수증 - 약 4건, 병원명 '튼튼정'은 약으로 잡히지 않는다") + void 별표형_영수증_약4건_병원명오탐없음() { // Given - List fields = OcrFixtureLoader.loadFields("pharmacy_receipt_yuseong.json"); + List fields = OcrFixtureLoader.loadFields("pharmacy_receipt_starred.json"); // When List drugs = ocrParser.parse(fields, OcrType.PRESCRIPTION).parsedDrugs(); @@ -65,7 +65,7 @@ class OcrParserRegressionTest { assertThat(drugs).hasSize(4); assertThat(drugs).extracting(ParsedOcrData::drugName) .containsExactly("아클펜정", "아트놀셋세미정", "모사피트정", "에페신정") - .doesNotContain("중앙정"); + .doesNotContain("튼튼정"); assertThat(drugs).allSatisfy(d -> { assertThat(d.dosagePerTime()).isEqualTo("1정"); assertThat(d.timesPerDay()).isEqualTo(2); diff --git a/src/test/java/com/piuda/callcare/global/util/PiiMaskerTest.java b/src/test/java/com/piuda/callcare/global/util/PiiMaskerTest.java index 3fe8ba2..c4aad4f 100644 --- a/src/test/java/com/piuda/callcare/global/util/PiiMaskerTest.java +++ b/src/test/java/com/piuda/callcare/global/util/PiiMaskerTest.java @@ -13,9 +13,10 @@ class PiiMaskerTest { @ParameterizedTest @ValueSource(strings = { "환자 900101-1234567 님", - "주민등록번호: 900101 - 1234567" + "주민등록번호: 900101 - 1234567", + "OCR가 하이픈을 놓친 경우 9001011234567" }) - @DisplayName("주민번호(6자리-7자리, 공백 허용)는 마스킹된다") + @DisplayName("주민번호(6자리 [-] 7자리)는 하이픈 유무와 무관하게 마스킹된다") void 주민번호_마스킹(String input) { String masked = PiiMasker.maskResidentNumber(input); @@ -24,14 +25,14 @@ class PiiMaskerTest { } @Test - @DisplayName("하이픈 있는 주민번호만 마스킹하고 나머지 숫자는 건드리지 않는다") - void 하이픈_있는_주민번호만_마스킹() { + @DisplayName("주민번호만 마스킹하고 교부번호·금액 등 다른 숫자는 건드리지 않는다") + void 다른_숫자는_유지() { String input = "교부번호 20260701-00042 / 환자 900101-1234567 / 금액 14,940원"; String masked = PiiMasker.maskResidentNumber(input); assertThat(masked) - .contains("20260701-00042") // 교부번호는 뒷자리 8자리라 패턴 불일치 → 유지 + .contains("20260701-00042") // 8자리-5자리 → 패턴 불일치, 유지 .contains("14,940원") .contains("******-*******") .doesNotContain("900101-1234567"); @@ -45,6 +46,16 @@ class PiiMaskerTest { assertThat(PiiMasker.maskResidentNumber(input)).isEqualTo(input); } + @ParameterizedTest + @ValueSource(strings = { + "코드 900101-12345678", // 뒷자리 8개 → 주민번호 아님 + "토큰 A1234567-1234567" // 앞이 주민번호 형식이 아님 + }) + @DisplayName("더 긴 숫자열 내부는 부분 마스킹하지 않는다 (원본 보존)") + void 부분_일치_방지(String input) { + assertThat(PiiMasker.maskResidentNumber(input)).isEqualTo(input); + } + @Test @DisplayName("null은 null을 반환한다") void null_처리() { diff --git a/src/test/resources/ocr/expected/manifest.json b/src/test/resources/ocr/expected/manifest.json index 8fc9b9d..96e8247 100644 --- a/src/test/resources/ocr/expected/manifest.json +++ b/src/test/resources/ocr/expected/manifest.json @@ -1,10 +1,10 @@ { "fixtures": [ { - "file": "pharmacy_receipt_yuseong.json", + "file": "pharmacy_receipt_starred.json", "ocrType": "PRESCRIPTION", "guard": true, - "note": "유성온누리약국 영수증 (별표형, 좌표 없음) - 현재 정상 동작, 회귀 가드", + "note": "약제비 영수증 (별표형 약 이름, 좌표 없음, 개인정보 익명화) - 현재 정상 동작, 회귀 가드", "expected": [ { "drugName": "아클펜정", "dosagePerTime": "1정", "timesPerDay": 2, "totalDays": 5 }, { "drugName": "아트놀셋세미정", "dosagePerTime": "1정", "timesPerDay": 2, "totalDays": 5 }, diff --git a/src/test/resources/ocr/fixtures/pharmacy_receipt_yuseong.json b/src/test/resources/ocr/fixtures/pharmacy_receipt_starred.json similarity index 95% rename from src/test/resources/ocr/fixtures/pharmacy_receipt_yuseong.json rename to src/test/resources/ocr/fixtures/pharmacy_receipt_starred.json index 58163da..d9b441b 100644 --- a/src/test/resources/ocr/fixtures/pharmacy_receipt_yuseong.json +++ b/src/test/resources/ocr/fixtures/pharmacy_receipt_starred.json @@ -1,6 +1,6 @@ { "version": "V2", - "requestId": "fixture-yuseong", + "requestId": "fixture-starred-receipt", "timestamp": 0, "images": [ { @@ -23,35 +23,35 @@ } }, { - "inferText": "조제약사: 박도현", + "inferText": "조제약사: 김조제", "lineBreak": true, "boundingPoly": { "vertices": [] } }, { - "inferText": "조제일자: 2026-06-27", + "inferText": "조제일자: 2025-01-02", "lineBreak": true, "boundingPoly": { "vertices": [] } }, { - "inferText": "환 자 성 명: 강철웅(만 24세/남)", + "inferText": "환 자 성 명: 홍길동(만 65세/남)", "lineBreak": true, "boundingPoly": { "vertices": [] } }, { - "inferText": "처방전교부번호: 20260627-01019", + "inferText": "처방전교부번호: 20250101-00001", "lineBreak": true, "boundingPoly": { "vertices": [] } }, { - "inferText": "처방전발행기관: 중앙정형외과의원", + "inferText": "처방전발행기관: 튼튼정형외과의원", "lineBreak": true, "boundingPoly": { "vertices": [] @@ -93,21 +93,21 @@ } }, { - "inferText": "2026-06-27", + "inferText": "2025-01-02", "lineBreak": true, "boundingPoly": { "vertices": [] } }, { - "inferText": "20260627-0047", + "inferText": "20250101-0001", "lineBreak": true, "boundingPoly": { "vertices": [] } }, { - "inferText": "강철웅", + "inferText": "홍길동", "lineBreak": true, "boundingPoly": { "vertices": [] @@ -142,7 +142,7 @@ } }, { - "inferText": "복약만료일 2026", + "inferText": "복약만료일 2025", "lineBreak": true, "boundingPoly": { "vertices": [] @@ -632,7 +632,7 @@ } }, { - "inferText": "101-13-65757", + "inferText": "000-00-00000", "lineBreak": true, "boundingPoly": { "vertices": [] @@ -646,14 +646,14 @@ } }, { - "inferText": "경기도 시흥시 도일로", + "inferText": "서울특별시 종로구 세종대로", "lineBreak": true, "boundingPoly": { "vertices": [] } }, { - "inferText": "124번길 2, 102호", + "inferText": "1, 1호", "lineBreak": true, "boundingPoly": { "vertices": [] @@ -667,7 +667,7 @@ } }, { - "inferText": "유성온누리약국", + "inferText": "행복약국", "lineBreak": true, "boundingPoly": { "vertices": [] @@ -681,7 +681,7 @@ } }, { - "inferText": "김미희", + "inferText": "이약사", "lineBreak": true, "boundingPoly": { "vertices": [] @@ -695,7 +695,7 @@ } }, { - "inferText": "2026-06-27", + "inferText": "2025-01-02", "lineBreak": true, "boundingPoly": { "vertices": []