Skip to content
Open
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
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ AI_RUNTIME_ENABLED=false
AI_RUNTIME_ENDPOINT=http://127.0.0.1:8000/internal/v1/analyses
AI_RUNTIME_RENEWAL_ENDPOINT=http://127.0.0.1:8000/internal/v1/workflows/renewal/run
AI_DOCUMENT_GENERATION_ENDPOINT=http://127.0.0.1:8000/api/v1/documents/generate
AI_DOCUMENT_CONVERSION_ENDPOINT=http://127.0.0.1:8000/api/v1/documents/convert
AI_DOCUMENT_CONVERSION_TIMEOUT=60s
# AI_RUNTIME_SERVICE_CREDENTIAL=
AI_RUNTIME_CONNECT_TIMEOUT=2s
AI_RUNTIME_OVERALL_TIMEOUT=240s
Expand Down
2 changes: 2 additions & 0 deletions compose.demo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ services:
SPRING_MAIL_PROPERTIES_MAIL_SMTP_WRITETIMEOUT: ${SPRING_MAIL_PROPERTIES_MAIL_SMTP_WRITETIMEOUT:-5000}
AI_RUNTIME_ENABLED: ${AI_RUNTIME_ENABLED:-false}
AI_RUNTIME_ENDPOINT: ${AI_RUNTIME_ENDPOINT:-http://host.docker.internal:8000/internal/v1/analyses}
AI_DOCUMENT_CONVERSION_ENDPOINT: ${AI_DOCUMENT_CONVERSION_ENDPOINT:-http://host.docker.internal:8000/api/v1/documents/convert}
AI_DOCUMENT_CONVERSION_TIMEOUT: ${AI_DOCUMENT_CONVERSION_TIMEOUT:-60s}
AI_RUNTIME_SERVICE_CREDENTIAL: ${AI_RUNTIME_SERVICE_CREDENTIAL:-}
WORKER_PORTAL_BASE_URL: ${WORKER_PORTAL_BASE_URL:-http://localhost:5173}
WORKER_LINK_SMS_PROVIDER: ${WORKER_LINK_SMS_PROVIDER:-none}
Expand Down
2 changes: 2 additions & 0 deletions docs/ai-runtime-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,8 @@ AI_RUNTIME_SERVICE_CREDENTIAL=<배포 환경 Secret>
| `AI_RUNTIME_OVERALL_TIMEOUT` | `15s` | 연결·요청·응답 수신 전체의 Server 상한 |
| `AI_RUNTIME_MAX_RESPONSE_BYTES` | `1048576` | 응답을 메모리에 받기 전 적용하는 최대 크기 |
| `AI_DOCUMENT_GENERATION_ENDPOINT` | `http://127.0.0.1:8000/api/v1/documents/generate` | Renewal 문서 생성 API |
| `AI_DOCUMENT_CONVERSION_ENDPOINT` | `http://127.0.0.1:8000/api/v1/documents/convert` | HWP·HWPX를 PDF 미리보기로 변환하는 API |
| `AI_DOCUMENT_CONVERSION_TIMEOUT` | `60s` | 사용자 요청 안에서 문서 변환을 기다리는 최대 시간 |
| `AI_DOCUMENT_GENERATION_MAX_RESPONSE_BYTES` | `20971520` | 생성 파일을 메모리에 받기 전 적용하는 최대 크기 |
| `AI_RUNTIME_MAX_CONCURRENT_CALLS` | `8` | Server 한 인스턴스가 동시에 보내는 최대 호출 수 |
| `AI_RUNTIME_CIRCUIT_BREAKER_FAILURE_THRESHOLD` | `5` | 연속 장애 후 호출을 잠시 막는 기준 |
Expand Down
1 change: 1 addition & 0 deletions docs/api-documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ JSON을 읽기 쉬운 화면으로 변환한 문서입니다.
- 팀 공유 사이트: <https://fowoco.github.io/server/api/>
- OpenAPI JSON: <https://fowoco.github.io/server/api/openapi.json>
- 로컬 Swagger UI: <http://localhost:8080/swagger-ui.html>
- 파일 미리보기·합성 문서 확인: [file-preview.md](file-preview.md)

## 어떤 문서인가요?

Expand Down
99 changes: 99 additions & 0 deletions docs/file-preview.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# 문서 미리보기와 합성 데이터 품질 확인

## 한눈에 보기

HR 담당자가 문서함에서 파일을 내려받기 전에 내용을 확인할 수 있도록 다음 API를 사용한다.

```http
GET /api/v1/files/{fileId}/preview
Authorization: Bearer <access-token>
```

| 원본 형식 | Server 동작 | 응답 형식 |
| --- | --- | --- |
| PDF | 원본을 브라우저에 바로 표시 | `application/pdf` |
| JPG·PNG·WEBP | 원본을 브라우저에 바로 표시 | 원본 이미지 MIME |
| HWP·HWPX | AI 문서 변환 API로 PDF 변환 | `application/pdf` |
| 그 외 형식 | 미리보기 거부 | `415 FILE_PREVIEW_UNSUPPORTED` |

원본 다운로드는 기존 `GET /api/v1/files/{fileId}/content`를 그대로 사용한다. 미리보기는
원본 파일이나 DB 레코드를 변경하지 않고 필요할 때만 변환하므로 별도 Flyway Migration이 없다.

## 처리 흐름

```text
Client 미리보기 클릭
→ Server가 JWT 역할과 companyId 확인
→ stored_file과 저장소 원본 조회
→ PDF·이미지는 inline 반환
→ HWP·HWPX는 AI /api/v1/documents/convert 호출
→ PDF signature와 응답 크기 검증
→ Client에 inline PDF 반환
```

다른 사업장의 fileId는 파일 존재 여부가 드러나지 않도록 `404`로 처리한다. 응답에는
`Cache-Control: no-store`, `Content-Disposition: inline`, `X-Content-Type-Options: nosniff`를
적용한다. AI URL·인증 토큰·오류 본문은 Client 응답과 일반 로그에 노출하지 않는다.

## 로컬에서 확인하기

PDF와 이미지 미리보기는 AI 없이 확인할 수 있다. HWP·HWPX 미리보기까지 확인하려면 AI
문서 변환 서버와 LibreOffice 변환 기능이 실행 중이어야 한다.

```dotenv
AI_RUNTIME_ENABLED=true
AI_DOCUMENT_CONVERSION_ENDPOINT=http://127.0.0.1:8000/api/v1/documents/convert
AI_DOCUMENT_CONVERSION_TIMEOUT=60s
AI_RUNTIME_SERVICE_CREDENTIAL=<AI의 FOWOCO_INTERNAL_API_TOKEN과 같은 값>
```

1. AI와 Server를 실행한다.
2. <http://localhost:8080/swagger-ui.html>에서 로그인한다.
3. 반환된 Access Token을 `Authorize`에 입력한다.
4. `GET /api/v1/files/{fileId}/preview`에 문서의 `fileId`를 넣어 실행한다.
5. `200`, `Content-Type: application/pdf`, `Content-Disposition: inline`을 확인한다.

AI가 꺼져 있어도 PDF·이미지 미리보기는 정상 동작한다. 이 상태에서 HWP·HWPX를 요청하면
무한 대기나 빈 파일 대신 `503 FILE_PREVIEW_UNAVAILABLE`을 반환한다.

## 오류 기준

| HTTP | 의미 | 담당자 행동 |
| --- | --- | --- |
| 404 | 파일이 없거나 다른 사업장 파일 | 올바른 문서인지 확인 |
| 415 | 지원하지 않는 형식 | 원본 다운로드 사용 |
| 422 | 손상됐거나 변환할 수 없는 HWP·HWPX | 원본 파일 교체·재생성 |
| 503 | AI 변환 기능 비활성 또는 장애 | AI·LibreOffice 설정 확인 후 재시도 |

## 합성 문서 품질 후속 확인

PR #183의 합성 문서가 병합된 뒤 아래 항목을 문서별로 확인한다. 이 작업은 Preview API와
분리하여 진행해 다른 팀의 Seed 파일 변경과 충돌하지 않게 한다.

- Worker DB의 표시 이름·국적·체류기간과 문서 본문·메타데이터가 일치하는가?
- 텍스트 잘림, 빈 페이지, 겹침, 깨진 글꼴이 없는가?
- 실존 개인정보·기관 직인·공식 문서로 오인할 요소가 없고 `DEMO`·`NOT VALID` 표시가 있는가?
- OCR로 읽을 핵심 칸을 워터마크가 가리지 않는가?
- PDF·이미지는 Preview API에서 열리고 HWP·HWPX는 PDF로 변환되는가?
- 여권번호·외국인등록번호 같은 값이 일반 로그와 오류 응답에 남지 않는가?

품질이 부족한 문서는 원본 생성 규칙을 수정하고, 수정 전후 Preview 화면과 확인한 필드 목록을
#186에 남긴다.

## 2026-08-16 실제 변환 확인 결과

최신 main과 AI main의 문서 변환 코드를 사용해 합성 Template 4개를 실제 LibreOffice 엔진으로
변환했다. Server의 HTTP 계약과 오류 처리는 정상이나, 아래 Provider 품질 문제가 확인되어
HWP·HWPX 미리보기는 아직 데모 활성화 대상이 아니다.

| 입력 파일 | 실제 결과 | 판정 |
| --- | --- | --- |
| `employment-contract-template.hwp` | PDF 생성, 136,363 bytes, 60페이지 | 깨진 문자와 비정상 페이지 수로 사용 불가 |
| `employment-contract-template.hwpx` | `source file could not be loaded` | 변환 실패 |
| `employment-extension-template.hwpx` | `source file could not be loaded` | 변환 실패 |
| `integrated-application-template.hwpx` | `source file could not be loaded` | 변환 실패 |

검증 명령과 환경은 macOS, LibreOffice headless, AI `HwpToPdfConverter`·`HwpxToPdfConverter`를
사용했다. 변환 실패 상태에서는 Server가 원본이나 DB를 변경하지 않고 422 또는 503으로
종료한다. 실제 변환 기능은 AI의 HWP/HWPX 렌더링 경로가 보완되고 동일 fixture Smoke Test가
통과한 뒤 활성화한다.
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import com.fowoco.server.aiintegration.application.validation.RenewalRuntimeContractValidator;
import com.fowoco.server.aiintegration.application.validation.ValidatingAiRuntimeClient;
import com.fowoco.server.aiintegration.application.validation.ValidatingRenewalRuntimeClient;
import com.fowoco.server.file.application.port.DocumentPreviewConverter;
import java.net.http.HttpClient;
import java.time.Clock;
import java.time.Duration;
Expand Down Expand Up @@ -112,6 +113,21 @@ public DocumentGenerationClient documentGenerationClient(
);
}

@Bean
public DocumentPreviewConverter documentPreviewConverter(AiRuntimeProperties properties) {
if (!properties.isEnabled()) {
return new DisabledDocumentPreviewConverter();
}
properties.validateEnabledConfiguration();
return new RemoteDocumentPreviewConverter(
properties.getDocumentConversionEndpoint(),
properties.authorizationHeader(),
properties.getDocumentConversionTimeout(),
properties.getMaxDocumentResponseBytes(),
createHttpClient(properties.getConnectTimeout())
);
}

static HttpClient createHttpClient(Duration connectTimeout) {
return HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ public final class AiRuntimeProperties implements AiRuntimeDeadlinePolicy {
private URI endpoint = URI.create("http://127.0.0.1:8000/internal/v1/analyses");
private URI renewalEndpoint = URI.create("http://127.0.0.1:8000/internal/v1/workflows/renewal/run");
private URI documentGenerationEndpoint = URI.create("http://127.0.0.1:8000/api/v1/documents/generate");
private URI documentConversionEndpoint = URI.create("http://127.0.0.1:8000/api/v1/documents/convert");
private String serviceCredential;
private Duration connectTimeout = Duration.ofSeconds(2);
private Duration overallTimeout = Duration.ofMinutes(4);
private Duration documentConversionTimeout = Duration.ofSeconds(60);
private int maxResponseBytes = 1_048_576;
private int maxDocumentResponseBytes = MAX_DOCUMENT_RESPONSE_BYTES;
private int maxConcurrentCalls = 8;
Expand Down Expand Up @@ -58,6 +60,14 @@ public void setDocumentGenerationEndpoint(URI documentGenerationEndpoint) {
this.documentGenerationEndpoint = requireHttpEndpoint(documentGenerationEndpoint);
}

public URI getDocumentConversionEndpoint() {
return documentConversionEndpoint;
}

public void setDocumentConversionEndpoint(URI documentConversionEndpoint) {
this.documentConversionEndpoint = requireHttpEndpoint(documentConversionEndpoint);
}

public void setServiceCredential(String serviceCredential) {
this.serviceCredential = serviceCredential;
}
Expand Down Expand Up @@ -91,6 +101,18 @@ public int getMaxResponseBytes() {
return maxResponseBytes;
}

public Duration getDocumentConversionTimeout() {
return documentConversionTimeout;
}

public void setDocumentConversionTimeout(Duration documentConversionTimeout) {
Duration validated = requirePositive(documentConversionTimeout, "documentConversionTimeout");
if (validated.compareTo(MAX_OVERALL_TIMEOUT) > 0) {
throw new IllegalArgumentException("documentConversionTimeout must not exceed 5m");
}
this.documentConversionTimeout = validated;
}

public void setMaxResponseBytes(int maxResponseBytes) {
if (maxResponseBytes < MIN_RESPONSE_BYTES || maxResponseBytes > MAX_RESPONSE_BYTES) {
throw new IllegalArgumentException("maxResponseBytes must be between 1 KiB and 10 MiB");
Expand Down Expand Up @@ -159,9 +181,11 @@ void validateEnabledConfiguration() {
requireHttpEndpoint(endpoint);
requireHttpEndpoint(renewalEndpoint);
requireHttpEndpoint(documentGenerationEndpoint);
requireHttpEndpoint(documentConversionEndpoint);
authorizationHeader();
requirePositive(connectTimeout, "connectTimeout");
requirePositive(overallTimeout, "overallTimeout");
requirePositive(documentConversionTimeout, "documentConversionTimeout");
}

private static URI requireHttpEndpoint(URI value) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.fowoco.server.aiintegration.infrastructure.http;

import com.fowoco.server.file.application.DocumentPreviewConversionException;
import com.fowoco.server.file.application.DocumentPreviewSource;
import com.fowoco.server.file.application.port.DocumentPreviewConverter;

final class DisabledDocumentPreviewConverter implements DocumentPreviewConverter {

@Override
public byte[] convertToPdf(DocumentPreviewSource source) {
throw new DocumentPreviewConversionException(
DocumentPreviewConversionException.Reason.UNAVAILABLE,
"Document preview conversion is disabled."
);
}
}
Loading
Loading