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
16 changes: 16 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,22 @@ AI_RUNTIME_MAX_CONCURRENT_CALLS=8
AI_RUNTIME_CIRCUIT_BREAKER_FAILURE_THRESHOLD=5
AI_RUNTIME_CIRCUIT_BREAKER_OPEN_DURATION=30s

# 여권·외국인등록증 OCR은 일반 분석과 별도 endpoint/스위치를 사용합니다.
# 아래 두 enabled 값과 암호화 키가 모두 준비된 통합 환경에서만 켭니다.
AI_OCR_ENABLED=false
AI_OCR_ENDPOINT=http://127.0.0.1:8000/internal/v1/ocr/worker-documents
# AI_OCR_SERVICE_CREDENTIAL=
AI_OCR_CONNECT_TIMEOUT=2s
AI_OCR_OVERALL_TIMEOUT=20s
AI_OCR_MAX_RESPONSE_BYTES=1048576
AI_OCR_MAX_CONCURRENT_CALLS=4
AI_OCR_CIRCUIT_BREAKER_FAILURE_THRESHOLD=5
AI_OCR_CIRCUIT_BREAKER_OPEN_DURATION=30s
DOCUMENT_OCR_ENABLED=false
# OCR 결과는 DB에 평문으로 저장하지 않습니다. 아래 값은 32바이트 난수의 Base64입니다.
# 생성 예시: openssl rand -base64 32
# OCR_RESULT_ENCRYPTION_KEY_BASE64=
OCR_RESULT_KEY_VERSION=local-v1
WORKER_IMPORT_SOURCE_RETENTION=7d

# Client용 AiRun 상태 SSE입니다. 실행 기준은 SSE가 아니라 DB와 GET /api/v1/ai-runs/{id}입니다.
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,8 @@ src/main/java/com/fowoco/server/

- 사업장 데이터는 인증 Context의 `company_id`로 격리합니다.
- Client가 보낸 `company_id`를 신뢰하지 않습니다.
- 외국인등록번호·여권번호·전화번호·계좌번호를 AI 입력과 일반 로그에 넣지 않습니다.
- 일반 자연어 분석 JSON에는 외국인등록번호·여권번호·전화번호·계좌번호를 넣지 않습니다.
- OCR은 HR이 선택한 서류 파일만 전용 내부 API로 전송합니다. 실행은 Outbox로 복구하고, 원본 추출값과 HR 수정값을 분리해 암호화 저장하며 일반 로그에는 값 대신 수정한 필드명만 남깁니다.
- AI 결과와 요청 초안은 HR 승인 전 자동 발송하지 않습니다.
- 중요한 변경은 actor, 시각, `request_id`와 함께 감사로그에 남깁니다.
- Worker Link 원본 token, JWT, API Key와 비밀번호를 GitHub·로그·문서에 남기지 않습니다.
Expand All @@ -202,5 +203,5 @@ src/main/java/com/fowoco/server/
- 외부기관 자동 로그인·자동 제출
- AI의 법률·노무 최종 판단
- 자체 학습 모델의 필수 서비스 탑재
- OCR·대용량 파일 처리 전체 구현
- 범용 OCR·대용량 일괄 파일 처리
- 실제 Blue/Green Agent 트래픽 전환
6 changes: 6 additions & 0 deletions compose.demo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ services:
AI_RUNTIME_ENABLED: ${AI_RUNTIME_ENABLED:-false}
AI_RUNTIME_ENDPOINT: ${AI_RUNTIME_ENDPOINT:-http://host.docker.internal:8000/internal/v1/analyses}
AI_RUNTIME_SERVICE_CREDENTIAL: ${AI_RUNTIME_SERVICE_CREDENTIAL:-}
AI_OCR_ENABLED: ${AI_OCR_ENABLED:-false}
AI_OCR_ENDPOINT: ${AI_OCR_ENDPOINT:-http://host.docker.internal:8000/internal/v1/ocr/worker-documents}
AI_OCR_SERVICE_CREDENTIAL: ${AI_OCR_SERVICE_CREDENTIAL:-}
DOCUMENT_OCR_ENABLED: ${DOCUMENT_OCR_ENABLED:-false}
OCR_RESULT_ENCRYPTION_KEY_BASE64: ${OCR_RESULT_ENCRYPTION_KEY_BASE64:-}
OCR_RESULT_KEY_VERSION: ${OCR_RESULT_KEY_VERSION:-demo-v1}
DEMO_SEED_ENABLED: ${DEMO_SEED_ENABLED:-false}
DEMO_SEED_ADMIN_PASSWORD: ${DEMO_SEED_ADMIN_PASSWORD:-}
extra_hosts:
Expand Down
51 changes: 51 additions & 0 deletions docs/ai-runtime-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,40 @@ Template을 고를 수 있도록 alpha-3 코드(`VNM`, `PHL`)로 변환합니다
AI를 호출하지 않습니다. 국가를 추가할 때는 AI Template 배포, Server 변환표와 양쪽 계약
테스트를 함께 변경합니다. 외국인등록증(`ARC`) 요청에는 국가 코드를 보내지 않습니다.

## OCR 실행·저장 경계

HR 화면은 AI를 직접 호출하지 않고 다음 Server API를 사용합니다.

| API | 역할 |
| --- | --- |
| `POST /api/v1/documents/{documentId}/ocr-runs` | 실행 이력을 `QUEUED`로 먼저 저장하고 202 반환 |
| `GET /api/v1/documents/{documentId}/ocr-runs/{ocrRunId}` | 실행 상태와 HR 검토용 결과 조회 |
| `GET /api/v1/documents/{documentId}/ocr-runs/latest` | 해당 문서의 최신 실행 조회 |
| `POST /api/v1/documents/{documentId}/ocr-runs/{ocrRunId}/review` | HR의 수정값과 검토 완료·반려 기록 |

Server는 연결된 `stored_file`을 읽어 AI의
`POST /internal/v1/ocr/worker-documents/{workerDocumentId}`로 multipart 전송합니다.
AI는 DB에 접근하거나 결과를 저장하지 않습니다. OCR 요청은 실행 이력과 같은 트랜잭션에서
Outbox 이벤트로 저장됩니다. 서버가 중단되면 메모리 작업 대신 DB에 남은 이벤트 lease를
다른 인스턴스가 회수해 다시 실행합니다. 실행 중 중단된 트랜잭션은 `QUEUED`로 롤백되므로
영구적인 `RUNNING` 상태를 만들지 않습니다.

Server는 계약 검증을 통과한 `fields`, `field_confidences`, `review_reasons`를 하나의
AES-256-GCM 암호문으로 저장합니다. HR의 `corrected_fields`는 OCR 원본을 덮어쓰지 않고
별도 암호문으로 저장합니다. 감사로그에는 수정한 field key만 기록하며 여권번호 같은 실제
값은 일반 컬럼·감사로그·오류 메시지에 남기지 않습니다.

`READY_FOR_REVIEW`와 `REVIEW_REQUIRED` 모두 HR 확인 대상입니다. `APPROVE`는 OCR 검토를
완료했다는 뜻이며, Worker·Document·Agent slot을 자동 수정하지 않습니다. 확정값 반영은
별도 command와 권한 정책이 합의된 뒤 연결합니다.

Server는 AI 응답의 허용 field와 confidence뿐 아니라 다음도 다시 검증합니다.

- `SUCCEEDED` 결과의 문서 종류별 필수 field와 ISO `YYYY-MM-DD` 날짜
- 여권 국가와 Template ID의 일치
- ARC Template ID(`43024`, `43025`)와 `FRONT`·`BACK`의 일치
- 빈 `SUCCEEDED` 결과와 모순된 Template·면 정보 거부

## Server가 거부하는 응답

- 요청과 다른 `requestId`
Expand Down Expand Up @@ -275,6 +309,23 @@ Server 내부 요청의 `deadlineMs`와 `AI_RUNTIME_OVERALL_TIMEOUT` 중 더 짧
timeout으로 사용합니다. `deadlineMs` 자체는 Runtime JSON에 전송하지 않습니다. 따라서
상위 AiRun이 허용한 시간보다 오래 기다리지 않습니다.

OCR까지 활성화하려면 별도 Secret과 결과 암호화 키를 함께 설정합니다.

```dotenv
AI_OCR_ENABLED=true
AI_OCR_ENDPOINT=https://ai.example.com/internal/v1/ocr/worker-documents
AI_OCR_SERVICE_CREDENTIAL=<배포 환경 Secret>
DOCUMENT_OCR_ENABLED=true
OCR_RESULT_ENCRYPTION_KEY_BASE64=<32바이트 난수의 Base64>
OCR_RESULT_KEY_VERSION=demo-v1
```

`AI_OCR_ENABLED`만 켜거나 암호화 키 없이 `DOCUMENT_OCR_ENABLED`를 켜지 않습니다. 암호화
키는 Git에 저장하지 않고 배포 Secret으로 주입합니다. key version은 암호문과 함께 남겨
향후 KMS/Vault Adapter로 교체할 때 어떤 키로 생성했는지 추적합니다. AI #20의 Stateless
OCR 구현이 CI를 통과해 병합되고 실제 파일 smoke test까지 끝나기 전에는 두 기능 스위치를
운영에서 `false`로 유지합니다.

## 장애가 발생하면

| 상황 | 안전한 실패 코드 | 처리 방향 |
Expand Down
4 changes: 4 additions & 0 deletions docs/deployment-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ Secret은 Git과 Actions 로그에 값을 남기지 않고 `kubectl create secre
| AI | `AI_RUNTIME_ENABLED=true` | 실제 Runtime 연동 활성화 |
| AI | `AI_RUNTIME_ENDPOINT` | 예: `http://ai:8000/internal/v1/analyses` |
| AI | `AI_RUNTIME_SERVICE_CREDENTIAL` | Server↔AI 내부 Bearer credential |
| OCR | `AI_OCR_ENABLED=true`, `DOCUMENT_OCR_ENABLED=true` | AI OCR 호출과 Server 저장 기능 활성화 |
| OCR | `AI_OCR_ENDPOINT`, `AI_OCR_SERVICE_CREDENTIAL` | OCR 내부 endpoint와 Bearer credential |
| OCR | `OCR_RESULT_ENCRYPTION_KEY_BASE64` | 32바이트 OCR 결과 암호화 키의 Base64 |
| OCR | `OCR_RESULT_KEY_VERSION` | 암호화 키 식별 version |

비밀번호 재설정 메일을 실제로 발송할 때만 다음 값을 `server-env`에 추가합니다. 기본
`PASSWORD_RESET_NOTIFICATION_PROVIDER=none`에서는 메일을 발송하지 않습니다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,14 @@ public final class AiOcrPassportCountryCodeResolver {
"CN", "CHN",
"VN", "VNM"
);
private static final Set<String> SUPPORTED_OCR_COUNTRIES =
Set.copyOf(WORKER_TO_OCR_COUNTRY.values());
private static final Map<String, Long> OCR_TEMPLATE_IDS = Map.of(
"KOR", 43019L,
"PHL", 43021L,
"JPN", 43022L,
"CHN", 43023L,
"VNM", 43038L
);
private static final Set<String> SUPPORTED_OCR_COUNTRIES = OCR_TEMPLATE_IDS.keySet();

public String fromWorkerNationalityCode(String workerNationalityCode) {
if (workerNationalityCode == null) {
Expand All @@ -46,6 +52,10 @@ public boolean isSupportedOcrCountryCode(String countryCode) {
&& SUPPORTED_OCR_COUNTRIES.contains(countryCode);
}

public Long expectedTemplateId(String countryCode) {
return OCR_TEMPLATE_IDS.get(countryCode);
}

private void reject(AiRuntimeFailureCode code, String safeMessage) {
throw new AiRuntimeContractException(code, safeMessage);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,21 @@

import com.fowoco.server.aiintegration.application.error.AiRuntimeContractException;
import com.fowoco.server.aiintegration.application.error.AiRuntimeFailureCode;
import com.fowoco.server.aiintegration.application.ocr.AiOcrDocumentSide;
import com.fowoco.server.aiintegration.application.ocr.AiOcrDocumentType;
import com.fowoco.server.aiintegration.application.ocr.AiOcrPassportCountryCodeResolver;
import com.fowoco.server.aiintegration.application.ocr.AiOcrRequest;
import com.fowoco.server.aiintegration.application.ocr.AiOcrResponse;
import com.fowoco.server.aiintegration.application.ocr.AiOcrStatus;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
import org.springframework.stereotype.Component;

@Component
public final class AiOcrContractValidator {

public static final int MAX_FILE_BYTES = 20 * 1024 * 1024;
Expand All @@ -35,6 +41,16 @@ public final class AiOcrContractValidator {
"stay_expiration_date",
"residence_address_1"
);
private static final Set<String> PASSPORT_REQUIRED_FIELDS = Set.of(
"passport_number", "surname", "given_names", "date_of_birth", "passport_expiry_date"
);
private static final Set<String> DATE_FIELDS = Set.of(
"date_of_birth", "passport_issue_date", "passport_expiry_date", "stay_expiration_date"
);
private static final Map<Long, AiOcrDocumentSide> ARC_TEMPLATE_SIDES = Map.of(
43024L, AiOcrDocumentSide.FRONT,
43025L, AiOcrDocumentSide.BACK
);
private static final Pattern SAFE_REASON = Pattern.compile("[a-z0-9_:-]{1,120}");

private final AiOcrPassportCountryCodeResolver passportCountryCodeResolver =
Expand Down Expand Up @@ -90,14 +106,17 @@ public void validateResponse(AiOcrRequest request, AiOcrResponse response) {
if (value == null || value.isBlank() || value.length() > 500) {
reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "OCR response field value is invalid.");
}
if (DATE_FIELDS.contains(key)) {
validateIsoDate(value);
}
});
response.fieldConfidences().forEach(this::validateConfidence);
response.reviewReasons().forEach(reason -> {
if (reason == null || !SAFE_REASON.matcher(reason).matches()) {
reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "OCR review reason is invalid.");
}
});
validateOutcome(response);
validateOutcome(request, response);
}

private void validateConfidence(String key, BigDecimal confidence) {
Expand All @@ -108,7 +127,7 @@ private void validateConfidence(String key, BigDecimal confidence) {
}
}

private void validateOutcome(AiOcrResponse response) {
private void validateOutcome(AiOcrRequest request, AiOcrResponse response) {
if (response.status() == AiOcrStatus.SUCCEEDED && !response.reviewReasons().isEmpty()) {
reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "Successful OCR cannot require review.");
}
Expand All @@ -118,6 +137,80 @@ private void validateOutcome(AiOcrResponse response) {
if (response.matchedTemplateId() != null && response.matchedTemplateId() < 1) {
reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "OCR template id is invalid.");
}
validateTemplate(request, response);
if (response.status() == AiOcrStatus.SUCCEEDED) {
validateSuccessfulFields(request, response);
}
}

private void validateTemplate(AiOcrRequest request, AiOcrResponse response) {
Long templateId = response.matchedTemplateId();
if (templateId == null) {
if (response.status() == AiOcrStatus.SUCCEEDED
|| response.documentSide() != null
|| !response.fields().isEmpty()) {
reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "OCR template result is inconsistent.");
}
return;
}
if (request.documentType() == AiOcrDocumentType.PASSPORT_COPY) {
Long expected = passportCountryCodeResolver.expectedTemplateId(request.countryCode());
if (!templateId.equals(expected)) {
requireUnexpectedTemplateReview(response);
return;
}
if (response.documentSide() != null) {
reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "Passport OCR side must be omitted.");
}
return;
}
AiOcrDocumentSide expectedSide = ARC_TEMPLATE_SIDES.get(templateId);
if (expectedSide == null) {
requireUnexpectedTemplateReview(response);
return;
}
if (response.documentSide() != expectedSide) {
reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "ARC OCR template side does not match.");
}
}

private void requireUnexpectedTemplateReview(AiOcrResponse response) {
if (response.status() != AiOcrStatus.REVIEW_REQUIRED
|| !response.reviewReasons().contains("unexpected_template")
|| response.documentSide() != null
|| !response.fields().isEmpty()) {
reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "OCR template does not match the request.");
}
}

private void validateSuccessfulFields(AiOcrRequest request, AiOcrResponse response) {
if (response.fields().isEmpty()) {
reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "Successful OCR fields are empty.");
}
if (request.documentType() == AiOcrDocumentType.PASSPORT_COPY) {
if (!response.fields().keySet().containsAll(PASSPORT_REQUIRED_FIELDS)) {
reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "Passport OCR required fields are missing.");
}
return;
}
if (response.documentSide() == AiOcrDocumentSide.FRONT
&& !response.fields().containsKey("alien_registration_number")) {
reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "ARC front required field is missing.");
}
if (response.documentSide() == AiOcrDocumentSide.BACK
&& response.fields().keySet().stream().noneMatch(
key -> key.startsWith("stay_") || key.startsWith("residence_")
)) {
reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "ARC back required field is missing.");
}
}

private void validateIsoDate(String value) {
try {
LocalDate.parse(value);
} catch (DateTimeParseException exception) {
reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "OCR date field is invalid.");
}
}

private void reject(AiRuntimeFailureCode code, String safeMessage) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package com.fowoco.server.aiintegration.infrastructure.http;

import com.fowoco.server.aiintegration.application.port.AiOcrClient;
import com.fowoco.server.aiintegration.application.validation.AiOcrContractValidator;
import com.fowoco.server.aiintegration.application.validation.ValidatingAiOcrClient;
import java.net.http.HttpClient;
import java.time.Clock;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.PropertyNamingStrategies;
import tools.jackson.databind.cfg.CoercionAction;
import tools.jackson.databind.cfg.CoercionInputShape;
import tools.jackson.databind.type.LogicalType;

@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(AiOcrProperties.class)
public class AiOcrHttpConfiguration {

@Bean
public AiOcrClient aiOcrClient(
AiOcrProperties properties,
AiOcrContractValidator validator,
ObjectMapper applicationObjectMapper,
Clock clock
) {
if (!properties.isEnabled()) {
return new DisabledAiOcrClient();
}
properties.validateEnabledConfiguration();
ObjectMapper contractObjectMapper = createContractObjectMapper(applicationObjectMapper);
HttpClient httpClient = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1)
.connectTimeout(properties.getConnectTimeout())
.followRedirects(HttpClient.Redirect.NEVER)
.build();
AiRuntimeCircuitBreaker circuitBreaker = new AiRuntimeCircuitBreaker(
properties.getCircuitBreakerFailureThreshold(),
properties.getCircuitBreakerOpenDuration(),
clock
);
AiOcrClient remote = new RemoteAiOcrClient(
properties.getEndpoint(),
properties.authorizationHeader(),
properties.getOverallTimeout(),
properties.getMaxResponseBytes(),
properties.getMaxConcurrentCalls(),
httpClient,
contractObjectMapper,
circuitBreaker
);
return new ValidatingAiOcrClient(remote, validator);
}

static ObjectMapper createContractObjectMapper(ObjectMapper applicationObjectMapper) {
return applicationObjectMapper.rebuild()
.propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
.enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
.withCoercionConfig(LogicalType.Textual, config -> {
config.setCoercion(CoercionInputShape.Integer, CoercionAction.Fail);
config.setCoercion(CoercionInputShape.Float, CoercionAction.Fail);
config.setCoercion(CoercionInputShape.Boolean, CoercionAction.Fail);
})
.withCoercionConfig(LogicalType.Float, config ->
config.setCoercion(CoercionInputShape.String, CoercionAction.Fail))
.build();
}
}
Loading
Loading