Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 103 additions & 3 deletions docs/test-results/Gimini-3-#103-pdf-docx-document-parsing.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ WHERE extname = 'vector';

### 3.1 업로드

검증한 조합:
단위 테스트로 검증한 조합:

| Extension | Content-Type | 결과 |
| --- | --- | --- |
Expand All @@ -47,13 +47,16 @@ WHERE extname = 'vector';

TXT·Markdown의 기존 확장자와 Content-Type 조합도 단위 테스트로 함께 확인했다.

수동 검증 결과는 아래 8절에 기록한다.

### 3.2 PDF

메모리 PDF Fixture로 다음 계약을 검증했다.

- 두 Page의 Text와 1-based Page Number 보존
- Text가 없는 Page를 건너뛰고 다음 Page의 원래 번호 보존
- 전체 Page에 검색 가능한 Text가 없으면 `DOCUMENT-PARSING-006`
- Page가 하나도 없으면 `DOCUMENT-PARSING-002` (이슈 #110에서 회귀 테스트 추가)
- Password 보호 PDF면 `DOCUMENT-PARSING-005`
- 손상된 PDF면 `DOCUMENT-PARSING-007`

Expand Down Expand Up @@ -153,7 +156,103 @@ Could not resolve placeholder 'JWT_SECRET'
Repository 설정에 Secret을 기록하지 않고 일회성 Test 값을 Process 환경에 주입했다. 두 실패는
PDF·DOCX Parser 또는 PostgreSQL Schema 회귀가 아니며, 설정 보정 후 전체 Test가 통과했다.

## 8. OCR 판정
## 8. Swagger 수동 검증

이 절은 이슈 #110에서 사후 보완했다. 검증일은 2026-08-06이며, 앞선 자동 테스트와 같은 Build를
로컬에서 기동해 수행했다.

### 8.1 검증 환경

- 애플리케이션: 로컬 `bootRun`, `local` Profile, `http://localhost:8080`
- Swagger UI: `GET /swagger-ui/index.html` → `200`
- OpenAPI 문서: `GET /v3/api-docs`에 `POST /api/documents`(`multipart/form-data`, `DocumentUploadRequest`) 노출 확인
- 데이터베이스: 기존 개발 Schema를 사용하지 않고 일회용 Schema를 만들어 Flyway 37개 Migration 적용 후 검증 종료 시 삭제
- Object Storage: 검증 전용 임시 Bucket 사용 후 삭제
- 계정: 검증 전용 임시 사용자로 `POST /auth/signup` → `POST /auth/login`으로 토큰 발급, 실제 값은 기록하지 않음

Swagger UI가 노출하는 것과 동일한 Endpoint·Schema에 같은 `multipart/form-data` 요청을 보내 검증했다.
UI의 File Picker 조작만 자동화가 어려워 요청 전송에는 CLI를 사용했고, 요청 형식과 응답은 Swagger UI로
보내는 경우와 동일하다.

### 8.2 정상 케이스

요청:

```text
POST /api/documents
Authorization: Bearer <발급 토큰>
Content-Type: multipart/form-data
file=sample.pdf (type=application/pdf)
title=issue110 pdf allow
visibility=PRIVATE
```

기대 결과: `201`, 문서·Version·FileObject·Embedding Job 생성, 파싱과 임베딩은 수행하지 않음

실제 결과:

```json
{
"success": true,
"status": 201,
"data": {
"documentId": 5,
"documentVersionId": 5,
"fileObjectId": 1,
"embeddingJobId": 3,
"documentStatus": "UPLOADED",
"jobStatus": "PENDING"
}
}
```

DOCX도 `Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document`로 동일하게
`201`과 `jobStatus = PENDING`을 반환했다.

### 8.3 에러 케이스

요청:

```text
POST /api/documents
Authorization: Bearer <발급 토큰>
Content-Type: multipart/form-data
file=sample.doc (type=application/msword)
title=issue110 doc reject
visibility=PRIVATE
```

기대 결과: `400`, 지원하지 않는 확장자 오류

실제 결과:

```json
{
"status": 400,
"code": "DOCUMENT-FILE-003",
"message": "지원하지 않는 파일 확장자입니다.",
"method": "POST",
"path": "/api/documents",
"success": false
}
```

### 8.4 전체 조합 결과

| # | Extension | Content-Type | 기대 | 실제 |
| --- | --- | --- | --- | --- |
| 1 | `pdf` | `application/pdf` | 201 허용 | `201`, `jobStatus = PENDING` |
| 2 | `docx` | `application/vnd.openxmlformats-officedocument.wordprocessingml.document` | 201 허용 | `201`, `jobStatus = PENDING` |
| 3 | `pdf` | `application/octet-stream` | 400 거부 | `400`, `DOCUMENT-FILE-004` |
| 4 | `docx` | `application/pdf` | 400 거부 | `400`, `DOCUMENT-FILE-004` |
| 5 | `doc` | `application/msword` | 400 거부 | `400`, `DOCUMENT-FILE-003` |

3번과 4번은 확장자만으로 통과하지 않고 Content-Type까지 함께 확인한다는 점을, 5번은 구형 DOC 형식이
확장자 단계에서 걸러진다는 점을 확인한다. 5개 조합 모두 3.1절의 단위 테스트 결과와 일치했다.

검증에 사용한 PDF·DOCX·DOC 파일은 저장소 밖 임시 경로에서 생성했고 저장소에 추가하지 않았다.

## 9. OCR 판정

이번 구현은 OCR Engine을 포함하지 않는다.

Expand All @@ -165,11 +264,12 @@ PDF·DOCX Parser 또는 PostgreSQL Schema 회귀가 아니며, 설정 보정 후
실제 요구사항으로 확정되면 Tesseract를 별도 Process 또는 Container Adapter로 추가하고 Language Pack과
Native Runtime을 독립 배포하는 방식이 적합하다.

## 9. 최종 판정
## 10. 최종 판정

| 완료 조건 | 결과 |
| --- | --- |
| PDF·DOCX 업로드 조합 | 통과 |
| Swagger 노출 Endpoint 수동 검증 (정상 2 + 에러 3) | 통과 |
| PDF Page별 Text·Page Number | 통과 |
| DOCX Heading·본문·Table·Section Title | 통과 |
| 암호화·OCR 필요·손상·빈 문서 오류 | 통과 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ public class DocumentUploadController {

@Operation(
summary = "문서 업로드 접수",
description = "TXT 또는 Markdown 원본 파일을 저장하고 비동기 인덱싱 작업을 생성합니다. 파싱과 임베딩은 수행하지 않습니다."
description = "TXT, Markdown, PDF 또는 DOCX 원본 파일을 저장하고 비동기 인덱싱 작업을 생성합니다. "
+ "파싱과 임베딩은 수행하지 않습니다. 확장자와 Content-Type이 함께 맞아야 하며, "
+ "구형 DOC 형식은 지원하지 않습니다."
)
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<ApiResponse<DocumentUploadResponse>> upload(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

public record DocumentUploadRequest(
@NotNull
@Schema(description = "업로드할 TXT 또는 Markdown 파일", type = "string", format = "binary")
@Schema(description = "업로드할 TXT, Markdown, PDF 또는 DOCX 파일", type = "string", format = "binary")
MultipartFile file,

@NotBlank
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ public List<DocumentChunkDraft> chunk(ParsedDocument parsedDocument) {
// 1. Segment를 따로 Chunking해 PDF Page와 DOCX Section 경계가 한 Chunk에 섞이지 않게 한다.
for (int index = 0; index < parsedDocument.segments().size(); index++) {
ParsedDocumentSegment segment = parsedDocument.segments().get(index);
appendSegmentChunks(
// 2. Chunking이 이미 계산한 Segment의 Code Point 길이를 그대로 받아 같은 Text를 다시 순회하지 않는다.
int segmentLength = appendSegmentChunks(
drafts,
segment.text(),
globalOffset,
Expand All @@ -65,16 +66,24 @@ public List<DocumentChunkDraft> chunk(ParsedDocument parsedDocument) {
settings
);

// 2. 개념적 Canonical Text에서 Segment 사이 LF 한 개를 포함해 전역 Offset을 계산한다.
globalOffset += segment.text().codePointCount(0, segment.text().length());
// 3. 개념적 Canonical Text에서 Segment 사이 LF 한 개를 포함해 전역 Offset을 계산한다.
globalOffset += segmentLength;
if (index < parsedDocument.segments().size() - 1) {
globalOffset++;
}
}
return List.copyOf(drafts);
}

private void appendSegmentChunks(
/**
* 한 Segment를 Chunk Draft로 나눠 목록에 추가하고 그 Segment의 Code Point 길이를 반환한다.
*
* <p>호출자가 다음 Segment의 전역 Offset을 계산하려면 같은 길이가 필요하다. 여기서 이미 Code
* Point 배열을 만들므로 그 길이를 그대로 돌려주어 호출자가 같은 Text를 다시 순회하지 않게 한다.
*
* @return Segment의 Code Point 개수, 빈 Segment면 0
*/
private int appendSegmentChunks(
List<DocumentChunkDraft> drafts,
String segmentText,
int globalOffset,
Expand All @@ -85,17 +94,17 @@ private void appendSegmentChunks(
) {
int[] codePoints = segmentText.codePoints().toArray();
if (codePoints.length == 0) {
return;
return 0;
}

int step = settings.chunkSize() - settings.overlap();

// 3. 시작 위치를 Code Point 단위로 이동해 Surrogate Pair 중간 분할을 방지한다.
// 1. 시작 위치를 Code Point 단위로 이동해 Surrogate Pair 중간 분할을 방지한다.
for (int start = 0; start < codePoints.length; start += step) {
int end = Math.min(start + settings.chunkSize(), codePoints.length);
String chunkText = new String(codePoints, start, end - start);

// 4. 문서 전체 Index·Offset과 Segment 출처 Metadata를 같은 Draft에 고정한다.
// 2. 문서 전체 Index·Offset과 Segment 출처 Metadata를 같은 Draft에 고정한다.
drafts.add(new DocumentChunkDraft(
drafts.size(),
chunkText,
Expand All @@ -108,11 +117,12 @@ private void appendSegmentChunks(
metadataJson
));

// 5. 마지막 Chunk가 원문 끝에 도달하면 Overlap만 남은 추가 Chunk를 만들지 않는다.
// 3. 마지막 Chunk가 원문 끝에 도달하면 Overlap만 남은 추가 Chunk를 만들지 않는다.
if (end == codePoints.length) {
break;
}
}
return codePoints.length;
}

private ChunkSettings validatedSettings() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ void parseDocument_throwsWhenOcrIsRequired() throws IOException {
assertError(pdfWithPages(null, null), ErrorCode.DOCUMENT_OCR_REQUIRED);
}

@Test
@DisplayName("Page가 하나도 없는 PDF면 빈 문서 오류가 발생한다")
void parseDocument_throwsWhenPdfHasNoPage() throws IOException {
// Page 자체가 없으면 OCR로도 복구할 수 없으므로 스캔 PDF와 다른 오류로 구분해야 한다.
assertError(pdfWithPages(), ErrorCode.DOCUMENT_CONTENT_EMPTY);
}

@Test
@DisplayName("Password 보호 PDF면 암호화 문서 오류가 발생한다")
void parseDocument_throwsWhenPdfIsEncrypted() throws IOException {
Expand Down