diff --git a/backend/src/main/java/com/opensource/docgrid/domain/document/service/PdfDocumentParser.java b/backend/src/main/java/com/opensource/docgrid/domain/document/service/PdfDocumentParser.java index a9d1722b..b17f983e 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/document/service/PdfDocumentParser.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/document/service/PdfDocumentParser.java @@ -21,6 +21,7 @@ * PDFBox로 텍스트 PDF를 Page별 Segment로 변환한다. * *

암호화 PDF와 검색 가능한 Text가 없는 PDF를 일반 손상 문서와 구분하며, + * 폰트 인코딩이 깨져 추출 Text에 대체 문자(�)가 과도하게 섞인 문서를 차단한다. * 이미지 OCR과 Chunk 계산은 담당하지 않는다. */ @Slf4j @@ -28,6 +29,8 @@ public class PdfDocumentParser implements DocumentContentParser { private static final Set SUPPORTED_TYPES = Set.of(DocumentType.PDF); + private static final char REPLACEMENT_CHARACTER = '�'; + private static final double GARBLED_RATIO_THRESHOLD = 0.05; @Override public Set supportedTypes() { @@ -69,6 +72,10 @@ public ParsedDocument parseDocument(byte[] content) { if (segments.isEmpty()) { throw new DocGridException(ErrorCode.DOCUMENT_OCR_REQUIRED); } + // 4. 폰트 인코딩이 깨져 대체 문자(�)가 임계치를 넘으면 Chunk·Embedding 이전에 차단한다. + if (isGarbled(segments)) { + throw new DocGridException(ErrorCode.DOCUMENT_CONTENT_GARBLED); + } return new ParsedDocument(segments); } catch (InvalidPasswordException exception) { throw new DocGridException(ErrorCode.DOCUMENT_PDF_ENCRYPTED, exception); @@ -83,4 +90,15 @@ public ParsedDocument parseDocument(byte[] content) { private String canonicalize(String text) { return text.replace("\r\n", "\n").replace('\r', '\n').strip(); } + + static boolean isGarbled(List segments) { + long totalLength = 0; + long replacementCount = 0; + for (ParsedDocumentSegment segment : segments) { + String text = segment.text(); + totalLength += text.length(); + replacementCount += text.chars().filter(c -> c == REPLACEMENT_CHARACTER).count(); + } + return totalLength > 0 && (double) replacementCount / totalLength > GARBLED_RATIO_THRESHOLD; + } } diff --git a/backend/src/main/java/com/opensource/docgrid/domain/worker/service/WorkerIndexingFailureClassifier.java b/backend/src/main/java/com/opensource/docgrid/domain/worker/service/WorkerIndexingFailureClassifier.java index ebaddc20..f39f94af 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/worker/service/WorkerIndexingFailureClassifier.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/worker/service/WorkerIndexingFailureClassifier.java @@ -34,7 +34,8 @@ public class WorkerIndexingFailureClassifier { ErrorCode.DOCUMENT_TEXT_DECODING_FAILED, ErrorCode.DOCUMENT_PDF_ENCRYPTED, ErrorCode.DOCUMENT_OCR_REQUIRED, - ErrorCode.DOCUMENT_PARSING_FAILED + ErrorCode.DOCUMENT_PARSING_FAILED, + ErrorCode.DOCUMENT_CONTENT_GARBLED ); private static final Set EMBEDDING_RESULT_ERRORS = EnumSet.of( ErrorCode.EMBEDDING_DIMENSION_MISMATCH, diff --git a/backend/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java b/backend/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java index d66c190f..bfcc612e 100644 --- a/backend/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java +++ b/backend/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java @@ -137,6 +137,11 @@ public enum ErrorCode { "DOCUMENT-PARSING-007", "문서 내용을 읽을 수 없습니다." ), + DOCUMENT_CONTENT_GARBLED( + HttpStatus.UNPROCESSABLE_ENTITY, + "DOCUMENT-PARSING-008", + "문서 텍스트에 깨진 문자가 많아 처리할 수 없습니다." + ), DOCUMENT_CHUNKS_INCONSISTENT( HttpStatus.INTERNAL_SERVER_ERROR, "DOCUMENT-CHUNK-001", diff --git a/backend/src/test/java/com/opensource/docgrid/domain/document/service/PdfDocumentParserTest.java b/backend/src/test/java/com/opensource/docgrid/domain/document/service/PdfDocumentParserTest.java index 77f8464c..6e288e2b 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/document/service/PdfDocumentParserTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/document/service/PdfDocumentParserTest.java @@ -5,6 +5,7 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.util.List; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDPage; @@ -80,6 +81,42 @@ void parseDocument_throwsWhenPdfIsCorrupted() { assertError(new byte[] {1, 2, 3}, ErrorCode.DOCUMENT_PARSING_FAILED); } + @Test + @DisplayName("정상 Text로만 이루어진 PDF는 깨진 문자 검증에 걸리지 않는다") + void parseDocument_doesNotFlagCleanTextAsGarbled() throws IOException { + byte[] pdf = pdfWithPages("first page", "second page"); + + ParsedDocument result = parser.parseDocument(pdf); + + assertThat(result.segments()).hasSize(2); + } + + @Test + @DisplayName("깨진 문자 비율이 임계치 이하면 정상으로 판단한다") + void isGarbled_returnsFalse_whenRatioIsAtOrBelowThreshold() { + ParsedDocumentSegment segment = new ParsedDocumentSegment( + "�" + "a".repeat(19), 1, null, null + ); + + assertThat(PdfDocumentParser.isGarbled(List.of(segment))).isFalse(); + } + + @Test + @DisplayName("깨진 문자 비율이 임계치를 넘으면 손상으로 판단한다") + void isGarbled_returnsTrue_whenRatioExceedsThreshold() { + ParsedDocumentSegment segment = new ParsedDocumentSegment( + "�".repeat(2) + "a".repeat(19), 1, null, null + ); + + assertThat(PdfDocumentParser.isGarbled(List.of(segment))).isTrue(); + } + + @Test + @DisplayName("Segment가 없으면 손상으로 판단하지 않는다") + void isGarbled_returnsFalse_whenSegmentsAreEmpty() { + assertThat(PdfDocumentParser.isGarbled(List.of())).isFalse(); + } + private byte[] pdfWithPages(String... pageTexts) throws IOException { try (PDDocument document = new PDDocument()) { for (String pageText : pageTexts) { diff --git a/backend/src/test/java/com/opensource/docgrid/domain/worker/service/WorkerIndexingFailureClassifierTest.java b/backend/src/test/java/com/opensource/docgrid/domain/worker/service/WorkerIndexingFailureClassifierTest.java index 2730ca95..f42e4204 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/worker/service/WorkerIndexingFailureClassifierTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/worker/service/WorkerIndexingFailureClassifierTest.java @@ -73,6 +73,8 @@ void classify_mapsNonRetryableFailures() { .failureType()).isEqualTo(IndexingFailureType.DOCUMENT_CONTENT_INVALID); assertThat(classifier.classify(new DocGridException(ErrorCode.DOCUMENT_PARSING_FAILED)) .failureType()).isEqualTo(IndexingFailureType.DOCUMENT_CONTENT_INVALID); + assertThat(classifier.classify(new DocGridException(ErrorCode.DOCUMENT_CONTENT_GARBLED)) + .failureType()).isEqualTo(IndexingFailureType.DOCUMENT_CONTENT_INVALID); assertThat(classifier.classify(new DocGridException(ErrorCode.EMBEDDING_VECTOR_INVALID)) .failureType()).isEqualTo(IndexingFailureType.EMBEDDING_RESULT_INVALID); assertThat(classifier.classify(new DocGridException(ErrorCode.DOCUMENT_CHUNKS_INCONSISTENT)) diff --git a/docs/design/Gimini-3-#257-pdf-garbled-text-validation.md b/docs/design/Gimini-3-#257-pdf-garbled-text-validation.md new file mode 100644 index 00000000..3efc13e2 --- /dev/null +++ b/docs/design/Gimini-3-#257-pdf-garbled-text-validation.md @@ -0,0 +1,47 @@ +# #257 PDF 텍스트 추출 시 깨진 문자(�) 검증 누락 수정 + +closes #257 + +--- + +## 배경 + +`PdfDocumentParser`는 암호화·빈 문서·OCR 필요·파싱 실패 4가지만 구분한다. 텍스트가 추출은 되지만 PDF 폰트의 ToUnicode CMap이 깨져 대체 문자(U+FFFD, `�`)가 섞여 나오는 경우는 어떤 단계에서도 걸러내지 않았다. 이런 손상 텍스트는 검증 없이 그대로 Chunk → Embedding → RAG 검색까지 전달된다. + +에러코드·테스트를 직접 확인해 갭을 검증했다 — `DOCUMENT_CONTENT_INVALID` 류 에러코드도, `�` 비율 검사 로직도 코드베이스 어디에도 없었다. + +## 설계 결정 + +- **검사 단위는 문서 전체 통합.** 페이지별로 개별 차단하면 일부 페이지만 약간 깨진 정상 문서를 과도하게 차단할 수 있어, 모든 Segment의 Text를 합쳐 문서 전체 대비 `�` 비율로 판단한다. +- **임계치 5%는 코드 상수로 하드코딩.** 이번 최소 구현 범위에서는 `application.yml` 설정으로 노출하지 않는다. 운영 중 오탐/미탐 사례가 쌓이면 조정한다. +- **비율 계산은 `PdfDocumentParser` 내부 package-private static 메서드(`isGarbled`)로 분리.** PDFBox로 실제 손상 PDF를 인위적으로 재현하기 어려워(아래 테스트 항목 참고), 문자열을 직접 넣어 검증 로직만 단위 테스트할 수 있게 했다. +- **신규 `ErrorCode.DOCUMENT_CONTENT_GARBLED` (DOCUMENT-PARSING-008, 422)** — 기존 `DOCUMENT_PDF_ENCRYPTED`/`DOCUMENT_OCR_REQUIRED`/`DOCUMENT_PARSING_FAILED`와 동일하게 `WorkerIndexingFailureClassifier`의 `DOCUMENT_CONTENT_ERRORS` 집합에 등록해 `IndexingFailureType.DOCUMENT_CONTENT_INVALID`로 분류되고, 재처리 불가 정책을 그대로 상속받는다. 새 `IndexingFailureType`은 만들지 않았다. +- **스코프에서 제외한 것**: OCR 등 대체 추출 경로 도입, 임계치의 `application.yml` 설정화. 둘 다 이번 최소 구현 범위를 벗어난다고 판단해 뺐다. + +## API 명세 + +문서 업로드/버전 등록 시 PDF 파싱 단계에서 발생하는 에러 케이스가 하나 추가된다 (엔드포인트 자체는 변경 없음). + +**에러 케이스** + +| 상황 | 응답 | +| --- | --- | +| 문서 전체 대비 `�` 비율이 5% 초과 | `422 DOCUMENT_CONTENT_GARBLED` (DOCUMENT-PARSING-008) | +| (기존, 변경 없음) 암호화된 PDF | `422 DOCUMENT_PDF_ENCRYPTED` | +| (기존, 변경 없음) 텍스트를 찾을 수 없음 | `422 DOCUMENT_OCR_REQUIRED` | + +Worker 인덱싱 파이프라인에서는 `WorkerIndexingFailureClassifier`가 이 에러를 기존 `DOCUMENT_CONTENT_INVALID` 실패 유형으로 분류해, 관리자 화면에 다른 문서 내용 오류와 동일하게 노출된다. + +## 변경 파일 + +- `ErrorCode.java` — `DOCUMENT_CONTENT_GARBLED` (DOCUMENT-PARSING-008) 추가 +- `PdfDocumentParser.java` — `isGarbled()` 비율 검사 추가, Segment 추출 후 임계치 초과 시 차단 +- `WorkerIndexingFailureClassifier.java` — `DOCUMENT_CONTENT_ERRORS` 집합에 새 에러코드 등록 + +## 테스트 + +- `PdfDocumentParserTest` — `isGarbled()` 임계치 이하/초과/빈 리스트 3케이스, `parseDocument()` 정상 텍스트 오탐 방지 회귀 1케이스 추가 +- `WorkerIndexingFailureClassifierTest` — 새 에러코드가 `DOCUMENT_CONTENT_INVALID`로 분류되는지 검증 추가 +- `./backend/gradlew -p backend test --tests "*PdfDocumentParserTest*" --tests "*WorkerIndexingFailureClassifierTest*"` 통과 + +**알려진 한계**: PDFBox가 실제로 `�`를 출력하는 상황은 폰트 ToUnicode CMap이 깨진 손상 PDF에서만 발생하는데, 기존 테스트 헬퍼(Standard14 Helvetica + WinAnsiEncoding)로는 이런 손상을 인위적으로 만들 수 없다(WinAnsiEncoding이 U+FFFD 자체를 인코딩하지 못해 PDF 생성 단계에서 예외가 난다). 그래서 `parseDocument()` 레벨의 "실제 손상 PDF → `DOCUMENT_CONTENT_GARBLED` 발생" 통합 테스트는 이번에 포함하지 않았다. 실제로 문제가 발생했던 PDF 파일을 확보하면 fixture로 추가해 통합 테스트를 보강하는 것을 후속 작업으로 남긴다.