diff --git a/README.md b/README.md index 6902904..0cc0534 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ FOWOCO는 단순 번역 서비스가 아닙니다. 체류·계약·서류·신 | 개발·배포 DB | PostgreSQL + Flyway | | 보안 | JWT Access Token, `ADMIN`·`HR`·`VIEWER` 역할, `company_id` 기반 ActorContext | | 개발 기반 | Swagger UI, 공통 오류, `request_id`, CI 구성 완료 | -| AI·Workflow | Knowledge Catalog projection, Task·Checklist·승인·감사 구현. AiRun 연동은 후속 Issue | +| AI·Workflow | Knowledge Catalog projection, Task·Checklist·승인·감사와 AI Runtime 계약·방어 검증 구현. Remote 연동·AiRun은 후속 Issue | 계획 문서는 현재 동작하는 API가 아닙니다. 구현의 원본은 코드·테스트와 실행 시 생성되는 OpenAPI이고, 장기 아키텍처 결정은 [ADR](docs/adr/README.md), 계획 범위와 예시는 [API 카탈로그](https://github.com/fowoco/server/wiki/09-API-Specification)와 Issue에서 확인합니다. @@ -140,6 +140,22 @@ POST /api/v1/tasks/{taskId}/approval-requests - 상태 변경, 승인 기록, 감사 이벤트는 같은 DB transaction에 기록되므로 중간 하나가 실패하면 함께 되돌아갑니다. - `GET /api/v1/tasks/{taskId}/activities`는 화면용 안전 타임라인이고, `GET /api/v1/audit-events`는 ADMIN용 필터·cursor 조회입니다. 내부 snapshot 원문은 두 API에 노출하지 않습니다. +### AI Runtime 계약 기반 + +Server는 AI Runtime에 보낼 수 있는 field를 typed DTO로 제한하고, 전송 전과 응답 후에 +`ValidatingAiRuntimeClient`로 개인정보·request ID·version·worker·workflow·slot을 +검증합니다. + +- `AiRuntimeClient`는 Provider-neutral Port이며 OpenAI·Gemini SDK를 포함하지 않습니다. +- 테스트에서는 네트워크를 호출하지 않는 `FakeAiRuntimeClient`를 사용합니다. +- 실제 `RemoteAiRuntimeClient`와 `/internal/v1/analyses` HTTP 연결은 AI 저장소의 원본 + OpenAPI·JSON Schema가 release된 뒤 추가합니다. +- Remote Client는 자동 retry하지 않습니다. 후속 #24가 새 AiAttempt를 영속한 경우에만 + 다시 호출할 수 있습니다. + +요청·응답 예시와 차단 규칙은 +[Server ↔ AI Runtime 계약 기반](docs/ai-runtime-contract.md)에서 확인합니다. + ### PostgreSQL 개발 Profile ```bash @@ -175,6 +191,7 @@ export DEMO_SEED_ADMIN_PASSWORD='로컬 또는 배포 Secret의 12자 이상 값 | Profile | `local`은 H2, `dev`·`prod`는 PostgreSQL을 사용합니다. | `application.yaml` | | Flyway | 서버 시작 시 적용하지 않은 DB 변경 파일을 순서대로 실행합니다. | `db/migration` | | Workflow Catalog | Knowledge release의 Server용 read-only projection을 시작 시 검증합니다. | `workflow/` | +| AI Runtime 계약 | 외부 AI 요청·응답을 allow-list와 version으로 다시 검증합니다. | `aiintegration/` | | Security | JWT에서 ActorContext와 역할을 만들고 VIEWER의 쓰기 요청을 기본 차단합니다. | `SecurityConfig` | | Swagger | Controller의 API 설명을 브라우저 문서로 보여줍니다. | `OpenApiConfig` | | 공통 오류 | 모든 실패를 같은 JSON 구조로 반환합니다. | `common/error` | @@ -308,6 +325,7 @@ server/ ├── auth/ ├── worker/ ├── task/ + ├── aiintegration/ └── airun/ ``` @@ -338,6 +356,7 @@ server/ | --- | --- | | 전체 백엔드 목표·작업 순서 | [MVP Epic #2](https://github.com/fowoco/server/issues/2) | | 저장소·모듈·API·상태 결정 원본 | [Architecture Decision Records](docs/adr/README.md) | +| Server와 AI Runtime의 구현 계약 | [AI Runtime 계약 기반](docs/ai-runtime-contract.md) | | 계획 API와 사용자 흐름 | [Wiki API 카탈로그](https://github.com/fowoco/server/wiki/09-API-Specification) | | 사람이 읽는 상세 DTO·화면 기획 | [Notion API 명세](https://app.notion.com/p/f250e15aa74e82b8872581be4d7c6c3c?v=f280e15aa74e82ce8d6e8848514d41c3&pvs=23) | | 화면·사용 흐름 | [Figma](https://www.figma.com/design/eaOD8OXZOGq6vK4H9pGXNi/FOWOCO?node-id=143-2&t=YbytLHiwZ5m1IChO-1) | diff --git a/docs/ai-runtime-contract.md b/docs/ai-runtime-contract.md new file mode 100644 index 0000000..6d89c86 --- /dev/null +++ b/docs/ai-runtime-contract.md @@ -0,0 +1,143 @@ +# Server ↔ AI Runtime 계약 기반 + +이 문서는 `fowoco/server`가 별도 배포되는 `fowoco/ai` Runtime을 호출할 때 지켜야 하는 +최소 계약과 방어 규칙을 설명합니다. + +현재 단계는 **HTTP 연결 전 계약 기반**입니다. 실제 `/internal/v1/analyses` OpenAPI와 +Structured Output JSON Schema의 원본은 `fowoco/ai`가 소유하며, 원본 계약이 release되면 +Server의 `RemoteAiRuntimeClient`와 fixture를 그 version에 맞춰 연결합니다. + +## 초보자용 한 줄 설명 + +AI 서버에 무엇을 보낼 수 있는지 먼저 좁혀 놓고, AI가 돌려준 값도 그대로 믿지 않고 다시 +검사하는 안전문입니다. + +```text +AiRunWorker (#24, 후속) + → ValidatingAiRuntimeClient + 1. 요청 개인정보·허용 범위 검사 + 2. AiRuntimeClient transport를 정확히 한 번 호출 + 3. 응답 ID·version·worker·workflow·slot 재검사 + → FakeAiRuntimeClient (test) + → RemoteAiRuntimeClient (#8 후속) + → POST /internal/v1/analyses (fowoco/ai) +``` + +`AiRuntimeClient`는 OpenAI, Gemini, Anthropic 같은 Provider를 직접 호출하지 않습니다. +Prompt, Agent Pipeline, Provider retry와 모델 선택은 `fowoco/ai` 책임입니다. + +## 요청 계약 + +```json +{ + "requestId": "10000000-0000-0000-0000-000000000001", + "attemptId": "20000000-0000-0000-0000-000000000001", + "contractVersion": "1.0.0", + "requiredKnowledgeVersion": "0.2.0", + "deadlineMs": 10000, + "maskedInput": { + "maskedInstruction": "workerRef 30000000-0000-0000-0000-000000000001의 체류연장 준비", + "workers": [ + { + "workerRef": "30000000-0000-0000-0000-000000000001", + "preferredLanguage": "vi", + "workStatus": "ACTIVE", + "stayExpiryDate": "2026-12-31" + } + ], + "workflowConstraints": [ + { + "workflowId": "EXPIRY_RENEWAL", + "allowedSlotKeys": [ + "stay_expiry_date", + "contract_end_date", + "monthly_wage" + ] + } + ] + } +} +``` + +- `requestId`: Server 요청과 Runtime 응답을 같은 실행으로 연결합니다. +- `attemptId`: 한 번의 `AiRuntimeClient.analyze` 호출과 정확히 하나로 대응합니다. +- `contractVersion`: 양쪽이 같은 JSON 계약을 사용하는지 확인합니다. +- `requiredKnowledgeVersion`: Server와 Runtime이 같은 Workflow release를 사용하게 합니다. +- `deadlineMs`: 이번 시도 전체에서 남은 실행 시간입니다. +- `maskedInstruction`: 이름과 식별번호를 `workerRef`로 바꾼 자연어입니다. +- `workflowConstraints`: Knowledge projection에서 가져온 Workflow와 slot allow-list입니다. + +근로자 Context에는 여권번호, 외국인등록번호, 전화번호, 계좌번호, 법적 실명, 원본 문서와 +Worker Link token을 추가하지 않습니다. + +## 응답 계약 + +```json +{ + "requestId": "10000000-0000-0000-0000-000000000001", + "outcome": "REVIEW_REQUIRED", + "candidates": [ + { + "candidateRef": "candidate-1", + "workerRef": "30000000-0000-0000-0000-000000000001", + "workflowId": "EXPIRY_RENEWAL", + "extractedSlots": { + "stay_expiry_date": "2026-12-31" + }, + "missingSlots": [ + "contract_end_date", + "monthly_wage" + ], + "confidence": 0.92 + } + ], + "validationErrors": [], + "versions": { + "agentVersion": "agent-1.0.0", + "modelProvider": "openai", + "modelName": "gpt-5-mini", + "modelVersion": "2026-07-01", + "promptVersion": "prompt-3", + "contextPackVersion": "context-0.2.0", + "workflowCatalogVersion": "0.2.0", + "contractVersion": "1.0.0" + }, + "providerAttemptCount": 1, + "latencyMs": 245 +} +``` + +`NEEDS_INFO`와 `REVIEW_REQUIRED`는 정상 분석 결과입니다. 이 값은 AiRun의 기술적 +`FAILED` 상태와 섞지 않습니다. Candidate는 Task도 승인도 아니며, #24에서 HR이 채택한 +후에만 Server Task command로 전달됩니다. + +## Server가 거부하는 응답 + +- 요청과 다른 `requestId` +- 요청과 다른 contract 또는 Workflow Catalog version +- 요청에 없던 `workerRef`나 `workflowId` +- Workflow가 허용하지 않은 slot +- 0 미만 또는 1 초과 confidence +- 중복 candidate reference와 잘못된 outcome 구조 +- 여권번호·외국인등록번호·전화번호·계좌번호·Bearer Token·Secret이 섞인 값 + +거부 예외에는 발견한 원문을 넣지 않습니다. 앞으로 #24 AiAttempt에는 +`AiRuntimeFailureCode`와 `requestId` 같은 안전한 진단값만 저장합니다. + +## 테스트와 실제 구현의 차이 + +- `FakeAiRuntimeClient`: `src/test`에만 있으며 응답이나 예외를 순서대로 예약합니다. +- `ValidatingAiRuntimeClient`: transport 앞뒤에서 같은 방어 검증을 수행합니다. +- `RemoteAiRuntimeClient`: 아직 없습니다. AI 원본 계약 release 후 추가합니다. + +실제 HTTP 연결 PR에서는 다음을 추가로 검증합니다. + +1. `Service-Authorization`, `X-Request-Id`, `traceparent` 전달 +2. 알 수 없는 JSON field와 body size 제한 +3. connect/read/overall deadline +4. circuit breaker와 concurrency bulkhead +5. HTTP·parsing·contract 오류의 안정적인 분류 +6. contract fixture와 WireMock 통합 테스트 + +Remote Client는 자동 HTTP retry를 하지 않습니다. 다시 호출하려면 #24가 먼저 새로운 +AiAttempt를 DB에 기록해야 합니다. diff --git a/src/main/java/com/fowoco/server/aiintegration/application/error/AiRuntimeContractException.java b/src/main/java/com/fowoco/server/aiintegration/application/error/AiRuntimeContractException.java new file mode 100644 index 0000000..96f3eab --- /dev/null +++ b/src/main/java/com/fowoco/server/aiintegration/application/error/AiRuntimeContractException.java @@ -0,0 +1,20 @@ +package com.fowoco.server.aiintegration.application.error; + +import java.util.Objects; + +/** + * Contract failure that never includes the rejected raw value in its message. + */ +public final class AiRuntimeContractException extends RuntimeException { + + private final AiRuntimeFailureCode failureCode; + + public AiRuntimeContractException(AiRuntimeFailureCode failureCode, String safeMessage) { + super(safeMessage); + this.failureCode = Objects.requireNonNull(failureCode, "failureCode must not be null"); + } + + public AiRuntimeFailureCode failureCode() { + return failureCode; + } +} diff --git a/src/main/java/com/fowoco/server/aiintegration/application/error/AiRuntimeFailureCode.java b/src/main/java/com/fowoco/server/aiintegration/application/error/AiRuntimeFailureCode.java new file mode 100644 index 0000000..4387644 --- /dev/null +++ b/src/main/java/com/fowoco/server/aiintegration/application/error/AiRuntimeFailureCode.java @@ -0,0 +1,16 @@ +package com.fowoco.server.aiintegration.application.error; + +/** + * Stable, non-sensitive reason stored by a future AiAttempt. + */ +public enum AiRuntimeFailureCode { + INVALID_REQUEST_CONTRACT, + SENSITIVE_DATA_REJECTED, + INVALID_RESPONSE_CONTRACT, + REQUEST_ID_MISMATCH, + CONTRACT_VERSION_MISMATCH, + KNOWLEDGE_VERSION_MISMATCH, + UNEXPECTED_WORKER_REFERENCE, + UNEXPECTED_WORKFLOW, + UNEXPECTED_SLOT +} diff --git a/src/main/java/com/fowoco/server/aiintegration/application/model/AiAnalysisOutcome.java b/src/main/java/com/fowoco/server/aiintegration/application/model/AiAnalysisOutcome.java new file mode 100644 index 0000000..f3efd4d --- /dev/null +++ b/src/main/java/com/fowoco/server/aiintegration/application/model/AiAnalysisOutcome.java @@ -0,0 +1,11 @@ +package com.fowoco.server.aiintegration.application.model; + +/** + * A successful business outcome returned by the AI Runtime. + * + *

Low confidence and missing information are not transport failures.

+ */ +public enum AiAnalysisOutcome { + NEEDS_INFO, + REVIEW_REQUIRED +} diff --git a/src/main/java/com/fowoco/server/aiintegration/application/model/AiAnalysisRequest.java b/src/main/java/com/fowoco/server/aiintegration/application/model/AiAnalysisRequest.java new file mode 100644 index 0000000..6dc7a99 --- /dev/null +++ b/src/main/java/com/fowoco/server/aiintegration/application/model/AiAnalysisRequest.java @@ -0,0 +1,25 @@ +package com.fowoco.server.aiintegration.application.model; + +import java.util.Objects; +import java.util.UUID; + +/** + * Provider-neutral body for one Server-managed AI attempt. + */ +public record AiAnalysisRequest( + UUID requestId, + UUID attemptId, + String contractVersion, + String requiredKnowledgeVersion, + long deadlineMs, + MaskedAnalysisInput maskedInput +) { + + public AiAnalysisRequest { + Objects.requireNonNull(requestId, "requestId must not be null"); + Objects.requireNonNull(attemptId, "attemptId must not be null"); + Objects.requireNonNull(contractVersion, "contractVersion must not be null"); + Objects.requireNonNull(requiredKnowledgeVersion, "requiredKnowledgeVersion must not be null"); + Objects.requireNonNull(maskedInput, "maskedInput must not be null"); + } +} diff --git a/src/main/java/com/fowoco/server/aiintegration/application/model/AiAnalysisResponse.java b/src/main/java/com/fowoco/server/aiintegration/application/model/AiAnalysisResponse.java new file mode 100644 index 0000000..d28b869 --- /dev/null +++ b/src/main/java/com/fowoco/server/aiintegration/application/model/AiAnalysisResponse.java @@ -0,0 +1,29 @@ +package com.fowoco.server.aiintegration.application.model; + +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +/** + * Untrusted response returned by the AI Runtime and validated again by the Server. + */ +public record AiAnalysisResponse( + UUID requestId, + AiAnalysisOutcome outcome, + List candidates, + List validationErrors, + AiRuntimeVersions versions, + int providerAttemptCount, + long latencyMs +) { + + public AiAnalysisResponse { + Objects.requireNonNull(requestId, "requestId must not be null"); + Objects.requireNonNull(outcome, "outcome must not be null"); + Objects.requireNonNull(candidates, "candidates must not be null"); + Objects.requireNonNull(validationErrors, "validationErrors must not be null"); + Objects.requireNonNull(versions, "versions must not be null"); + candidates = List.copyOf(candidates); + validationErrors = List.copyOf(validationErrors); + } +} diff --git a/src/main/java/com/fowoco/server/aiintegration/application/model/AiCandidate.java b/src/main/java/com/fowoco/server/aiintegration/application/model/AiCandidate.java new file mode 100644 index 0000000..400202b --- /dev/null +++ b/src/main/java/com/fowoco/server/aiintegration/application/model/AiCandidate.java @@ -0,0 +1,31 @@ +package com.fowoco.server.aiintegration.application.model; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; + +/** + * Untrusted Workflow candidate returned by the AI Runtime. + */ +public record AiCandidate( + String candidateRef, + UUID workerRef, + String workflowId, + Map extractedSlots, + List missingSlots, + BigDecimal confidence +) { + + public AiCandidate { + Objects.requireNonNull(candidateRef, "candidateRef must not be null"); + Objects.requireNonNull(workerRef, "workerRef must not be null"); + Objects.requireNonNull(workflowId, "workflowId must not be null"); + Objects.requireNonNull(extractedSlots, "extractedSlots must not be null"); + Objects.requireNonNull(missingSlots, "missingSlots must not be null"); + Objects.requireNonNull(confidence, "confidence must not be null"); + extractedSlots = Map.copyOf(extractedSlots); + missingSlots = List.copyOf(missingSlots); + } +} diff --git a/src/main/java/com/fowoco/server/aiintegration/application/model/AiRuntimeVersions.java b/src/main/java/com/fowoco/server/aiintegration/application/model/AiRuntimeVersions.java new file mode 100644 index 0000000..7974db4 --- /dev/null +++ b/src/main/java/com/fowoco/server/aiintegration/application/model/AiRuntimeVersions.java @@ -0,0 +1,29 @@ +package com.fowoco.server.aiintegration.application.model; + +import java.util.Objects; + +/** + * Version evidence persisted with a future AiRun. + */ +public record AiRuntimeVersions( + String agentVersion, + String modelProvider, + String modelName, + String modelVersion, + String promptVersion, + String contextPackVersion, + String workflowCatalogVersion, + String contractVersion +) { + + public AiRuntimeVersions { + Objects.requireNonNull(agentVersion, "agentVersion must not be null"); + Objects.requireNonNull(modelProvider, "modelProvider must not be null"); + Objects.requireNonNull(modelName, "modelName must not be null"); + Objects.requireNonNull(modelVersion, "modelVersion must not be null"); + Objects.requireNonNull(promptVersion, "promptVersion must not be null"); + Objects.requireNonNull(contextPackVersion, "contextPackVersion must not be null"); + Objects.requireNonNull(workflowCatalogVersion, "workflowCatalogVersion must not be null"); + Objects.requireNonNull(contractVersion, "contractVersion must not be null"); + } +} diff --git a/src/main/java/com/fowoco/server/aiintegration/application/model/AiValidationError.java b/src/main/java/com/fowoco/server/aiintegration/application/model/AiValidationError.java new file mode 100644 index 0000000..24dda4d --- /dev/null +++ b/src/main/java/com/fowoco/server/aiintegration/application/model/AiValidationError.java @@ -0,0 +1,17 @@ +package com.fowoco.server.aiintegration.application.model; + +import java.util.Objects; + +/** + * Machine-readable validation result. Free-form Provider error messages are intentionally omitted. + */ +public record AiValidationError( + String code, + String field +) { + + public AiValidationError { + Objects.requireNonNull(code, "code must not be null"); + Objects.requireNonNull(field, "field must not be null"); + } +} diff --git a/src/main/java/com/fowoco/server/aiintegration/application/model/MaskedAnalysisInput.java b/src/main/java/com/fowoco/server/aiintegration/application/model/MaskedAnalysisInput.java new file mode 100644 index 0000000..3dfea28 --- /dev/null +++ b/src/main/java/com/fowoco/server/aiintegration/application/model/MaskedAnalysisInput.java @@ -0,0 +1,22 @@ +package com.fowoco.server.aiintegration.application.model; + +import java.util.List; +import java.util.Objects; + +/** + * Pseudonymized instruction and allow-listed context sent to the AI Runtime. + */ +public record MaskedAnalysisInput( + String maskedInstruction, + List workers, + List workflowConstraints +) { + + public MaskedAnalysisInput { + Objects.requireNonNull(maskedInstruction, "maskedInstruction must not be null"); + Objects.requireNonNull(workers, "workers must not be null"); + Objects.requireNonNull(workflowConstraints, "workflowConstraints must not be null"); + workers = List.copyOf(workers); + workflowConstraints = List.copyOf(workflowConstraints); + } +} diff --git a/src/main/java/com/fowoco/server/aiintegration/application/model/MaskedWorkerContext.java b/src/main/java/com/fowoco/server/aiintegration/application/model/MaskedWorkerContext.java new file mode 100644 index 0000000..191cb8f --- /dev/null +++ b/src/main/java/com/fowoco/server/aiintegration/application/model/MaskedWorkerContext.java @@ -0,0 +1,25 @@ +package com.fowoco.server.aiintegration.application.model; + +import java.time.LocalDate; +import java.util.Objects; +import java.util.UUID; + +/** + * Minimum worker context allowed to cross the AI boundary. + * + *

Legal name, phone, passport number, residence number, account number, and document contents + * must never be added here.

+ */ +public record MaskedWorkerContext( + UUID workerRef, + String preferredLanguage, + String workStatus, + LocalDate stayExpiryDate +) { + + public MaskedWorkerContext { + Objects.requireNonNull(workerRef, "workerRef must not be null"); + Objects.requireNonNull(preferredLanguage, "preferredLanguage must not be null"); + Objects.requireNonNull(workStatus, "workStatus must not be null"); + } +} diff --git a/src/main/java/com/fowoco/server/aiintegration/application/model/WorkflowConstraint.java b/src/main/java/com/fowoco/server/aiintegration/application/model/WorkflowConstraint.java new file mode 100644 index 0000000..8130ca7 --- /dev/null +++ b/src/main/java/com/fowoco/server/aiintegration/application/model/WorkflowConstraint.java @@ -0,0 +1,19 @@ +package com.fowoco.server.aiintegration.application.model; + +import java.util.Objects; +import java.util.Set; + +/** + * Knowledge-owned Workflow identifiers and slot names that the Runtime may return. + */ +public record WorkflowConstraint( + String workflowId, + Set allowedSlotKeys +) { + + public WorkflowConstraint { + Objects.requireNonNull(workflowId, "workflowId must not be null"); + Objects.requireNonNull(allowedSlotKeys, "allowedSlotKeys must not be null"); + allowedSlotKeys = Set.copyOf(allowedSlotKeys); + } +} diff --git a/src/main/java/com/fowoco/server/aiintegration/application/port/AiRuntimeClient.java b/src/main/java/com/fowoco/server/aiintegration/application/port/AiRuntimeClient.java new file mode 100644 index 0000000..c55a03f --- /dev/null +++ b/src/main/java/com/fowoco/server/aiintegration/application/port/AiRuntimeClient.java @@ -0,0 +1,15 @@ +package com.fowoco.server.aiintegration.application.port; + +import com.fowoco.server.aiintegration.application.model.AiAnalysisRequest; +import com.fowoco.server.aiintegration.application.model.AiAnalysisResponse; + +/** + * Server-owned port for one attempt against a separately deployed AI Runtime. + * + *

Implementations must not call a model Provider directly and must not retry transparently.

+ */ +@FunctionalInterface +public interface AiRuntimeClient { + + AiAnalysisResponse analyze(AiAnalysisRequest request); +} diff --git a/src/main/java/com/fowoco/server/aiintegration/application/validation/AiRuntimeContractValidator.java b/src/main/java/com/fowoco/server/aiintegration/application/validation/AiRuntimeContractValidator.java new file mode 100644 index 0000000..d1fcf60 --- /dev/null +++ b/src/main/java/com/fowoco/server/aiintegration/application/validation/AiRuntimeContractValidator.java @@ -0,0 +1,211 @@ +package com.fowoco.server.aiintegration.application.validation; + +import com.fowoco.server.aiintegration.application.error.AiRuntimeContractException; +import com.fowoco.server.aiintegration.application.error.AiRuntimeFailureCode; +import com.fowoco.server.aiintegration.application.model.AiAnalysisRequest; +import com.fowoco.server.aiintegration.application.model.AiAnalysisResponse; +import com.fowoco.server.aiintegration.application.model.AiCandidate; +import com.fowoco.server.aiintegration.application.model.AiRuntimeVersions; +import com.fowoco.server.aiintegration.application.model.MaskedWorkerContext; +import com.fowoco.server.aiintegration.application.model.WorkflowConstraint; +import java.math.BigDecimal; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.regex.Pattern; +import org.springframework.stereotype.Component; + +/** + * Semantic validation applied before an outbound call and after an untrusted Runtime response. + */ +@Component +public class AiRuntimeContractValidator { + + private static final long MIN_DEADLINE_MS = 100; + private static final long MAX_DEADLINE_MS = 60_000; + private static final int MAX_WORKERS = 20; + private static final int MAX_WORKFLOWS = 20; + private static final int MAX_CANDIDATES = 50; + private static final Pattern VERSION = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._+-]{0,63}"); + private static final Pattern IDENTIFIER = Pattern.compile("[A-Za-z][A-Za-z0-9._-]{0,127}"); + private static final Pattern CANDIDATE_REF = Pattern.compile("[A-Za-z0-9][A-Za-z0-9_-]{0,63}"); + + private final AiRuntimePrivacyPolicy privacyPolicy; + + public AiRuntimeContractValidator(AiRuntimePrivacyPolicy privacyPolicy) { + this.privacyPolicy = privacyPolicy; + } + + public void validateRequest(AiAnalysisRequest request) { + if (request == null) { + reject(AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT, "AI Runtime request is missing."); + } + validateVersion(request.contractVersion(), AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT); + validateVersion(request.requiredKnowledgeVersion(), AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT); + if (request.deadlineMs() < MIN_DEADLINE_MS || request.deadlineMs() > MAX_DEADLINE_MS) { + reject(AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT, "AI Runtime deadline is outside the allowed range."); + } + privacyPolicy.validateText(request.maskedInput().maskedInstruction(), 10_000, true); + validateWorkers(request); + validateWorkflowConstraints(request); + } + + public void validateResponse(AiAnalysisRequest request, AiAnalysisResponse response) { + validateRequest(request); + if (response == null) { + reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "AI Runtime response is missing."); + } + if (!request.requestId().equals(response.requestId())) { + reject(AiRuntimeFailureCode.REQUEST_ID_MISMATCH, "AI Runtime response requestId does not match."); + } + validateResponseVersions(request, response.versions()); + if (response.providerAttemptCount() < 1 || response.providerAttemptCount() > 10) { + reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "Provider attempt count is invalid."); + } + if (response.latencyMs() < 0 || response.latencyMs() > 86_400_000) { + reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "AI Runtime latency is invalid."); + } + if (response.candidates().size() > MAX_CANDIDATES) { + reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "AI Runtime returned too many candidates."); + } + + Map> allowedSlotsByWorkflow = allowedSlotsByWorkflow(request); + Set allowedWorkers = request.maskedInput().workers().stream() + .map(MaskedWorkerContext::workerRef) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); + Set candidateRefs = new HashSet<>(); + response.candidates().forEach(candidate -> + validateCandidate(candidate, allowedWorkers, allowedSlotsByWorkflow, candidateRefs)); + response.validationErrors().forEach(error -> { + validateIdentifier(error.code(), AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT); + privacyPolicy.validateKey(error.field()); + validateIdentifier(error.field(), AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT); + }); + if (response.outcome() == com.fowoco.server.aiintegration.application.model.AiAnalysisOutcome.REVIEW_REQUIRED + && response.candidates().isEmpty()) { + reject( + AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, + "REVIEW_REQUIRED response must include at least one candidate." + ); + } + } + + private void validateWorkers(AiAnalysisRequest request) { + var workers = request.maskedInput().workers(); + if (workers.isEmpty() || workers.size() > MAX_WORKERS) { + reject(AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT, "AI Runtime worker context count is invalid."); + } + Set workerRefs = new HashSet<>(); + for (MaskedWorkerContext worker : workers) { + if (!workerRefs.add(worker.workerRef())) { + reject(AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT, "AI Runtime worker reference is duplicated."); + } + privacyPolicy.validateText(worker.preferredLanguage(), 32, true); + privacyPolicy.validateText(worker.workStatus(), 32, true); + validateIdentifier(worker.preferredLanguage(), AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT); + validateIdentifier(worker.workStatus(), AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT); + } + } + + private void validateWorkflowConstraints(AiAnalysisRequest request) { + var workflows = request.maskedInput().workflowConstraints(); + if (workflows.isEmpty() || workflows.size() > MAX_WORKFLOWS) { + reject(AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT, "AI Runtime Workflow constraint count is invalid."); + } + Set workflowIds = new HashSet<>(); + for (WorkflowConstraint workflow : workflows) { + validateIdentifier(workflow.workflowId(), AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT); + if (!workflowIds.add(workflow.workflowId()) || workflow.allowedSlotKeys().size() > 100) { + reject(AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT, "AI Runtime Workflow constraint is invalid."); + } + workflow.allowedSlotKeys().forEach(slot -> { + privacyPolicy.validateKey(slot); + validateIdentifier(slot, AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT); + }); + } + } + + private void validateResponseVersions(AiAnalysisRequest request, AiRuntimeVersions versions) { + validateVersion(versions.agentVersion(), AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT); + validateIdentifier(versions.modelProvider(), AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT); + privacyPolicy.validateText(versions.modelName(), 128, true); + validateVersion(versions.modelVersion(), AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT); + validateVersion(versions.promptVersion(), AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT); + validateVersion(versions.contextPackVersion(), AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT); + validateVersion(versions.workflowCatalogVersion(), AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT); + validateVersion(versions.contractVersion(), AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT); + + if (!request.contractVersion().equals(versions.contractVersion())) { + reject(AiRuntimeFailureCode.CONTRACT_VERSION_MISMATCH, "AI Runtime contract version does not match."); + } + if (!request.requiredKnowledgeVersion().equals(versions.workflowCatalogVersion())) { + reject(AiRuntimeFailureCode.KNOWLEDGE_VERSION_MISMATCH, "AI Runtime Knowledge version does not match."); + } + } + + private void validateCandidate( + AiCandidate candidate, + Set allowedWorkers, + Map> allowedSlotsByWorkflow, + Set candidateRefs + ) { + if (!CANDIDATE_REF.matcher(candidate.candidateRef()).matches() || !candidateRefs.add(candidate.candidateRef())) { + reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "AI Runtime candidate reference is invalid."); + } + if (!allowedWorkers.contains(candidate.workerRef())) { + reject(AiRuntimeFailureCode.UNEXPECTED_WORKER_REFERENCE, "AI Runtime returned an unknown worker reference."); + } + Set allowedSlots = allowedSlotsByWorkflow.get(candidate.workflowId()); + if (allowedSlots == null) { + reject(AiRuntimeFailureCode.UNEXPECTED_WORKFLOW, "AI Runtime returned an unexpected Workflow."); + } + if (candidate.confidence().compareTo(BigDecimal.ZERO) < 0 + || candidate.confidence().compareTo(BigDecimal.ONE) > 0) { + reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "AI Runtime confidence is invalid."); + } + candidate.extractedSlots().forEach((key, value) -> { + validateAllowedSlot(key, allowedSlots); + privacyPolicy.validateText(value, 4_000, true); + }); + Set missingSlots = new HashSet<>(); + candidate.missingSlots().forEach(slot -> { + validateAllowedSlot(slot, allowedSlots); + if (!missingSlots.add(slot) || candidate.extractedSlots().containsKey(slot)) { + reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "AI Runtime missing slot is invalid."); + } + }); + } + + private void validateAllowedSlot(String slot, Set allowedSlots) { + privacyPolicy.validateKey(slot); + validateIdentifier(slot, AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT); + if (!allowedSlots.contains(slot)) { + reject(AiRuntimeFailureCode.UNEXPECTED_SLOT, "AI Runtime returned an unexpected slot."); + } + } + + private Map> allowedSlotsByWorkflow(AiAnalysisRequest request) { + Map> allowed = new HashMap<>(); + request.maskedInput().workflowConstraints() + .forEach(workflow -> allowed.put(workflow.workflowId(), workflow.allowedSlotKeys())); + return Map.copyOf(allowed); + } + + private void validateVersion(String version, AiRuntimeFailureCode failureCode) { + if (version == null || !VERSION.matcher(version).matches()) { + reject(failureCode, "AI Runtime version is invalid."); + } + } + + private void validateIdentifier(String identifier, AiRuntimeFailureCode failureCode) { + if (identifier == null || !IDENTIFIER.matcher(identifier).matches()) { + reject(failureCode, "AI Runtime identifier is invalid."); + } + } + + private void reject(AiRuntimeFailureCode failureCode, String safeMessage) { + throw new AiRuntimeContractException(failureCode, safeMessage); + } +} diff --git a/src/main/java/com/fowoco/server/aiintegration/application/validation/AiRuntimePrivacyPolicy.java b/src/main/java/com/fowoco/server/aiintegration/application/validation/AiRuntimePrivacyPolicy.java new file mode 100644 index 0000000..e851c38 --- /dev/null +++ b/src/main/java/com/fowoco/server/aiintegration/application/validation/AiRuntimePrivacyPolicy.java @@ -0,0 +1,97 @@ +package com.fowoco.server.aiintegration.application.validation; + +import com.fowoco.server.aiintegration.application.error.AiRuntimeContractException; +import com.fowoco.server.aiintegration.application.error.AiRuntimeFailureCode; +import java.text.Normalizer; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Pattern; +import org.springframework.stereotype.Component; + +/** + * Rejects sensitive values before they cross the AI boundary or enter an AiRun candidate. + */ +@Component +public class AiRuntimePrivacyPolicy { + + private static final Set FORBIDDEN_KEY_PARTS = Set.of( + "passportnumber", + "passportno", + "alienregistrationnumber", + "registrationnumber", + "residentnumber", + "rrn", + "phone", + "accountnumber", + "bankaccount", + "token", + "password", + "secret", + "authorization", + "prompt", + "여권번호", + "외국인등록번호", + "주민등록번호", + "전화", + "계좌", + "비밀번호" + ); + private static final Pattern REGISTRATION_NUMBER = + Pattern.compile("(? maxLength) { + reject(AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT, "AI contract text exceeds its size limit."); + } + if (containsSensitiveValue(normalized)) { + reject(AiRuntimeFailureCode.SENSITIVE_DATA_REJECTED, "Sensitive data was rejected at the AI boundary."); + } + } + + public void validateKey(String key) { + if (key == null || key.isBlank()) { + reject(AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT, "AI contract key is missing."); + } + String normalizedKey = Normalizer.normalize(key, Normalizer.Form.NFKC) + .toLowerCase(Locale.ROOT) + .replace("_", "") + .replace("-", ""); + boolean forbidden = FORBIDDEN_KEY_PARTS.stream() + .map(part -> part.replace("_", "").replace("-", "")) + .anyMatch(normalizedKey::contains); + if (forbidden) { + reject(AiRuntimeFailureCode.SENSITIVE_DATA_REJECTED, "Sensitive key was rejected at the AI boundary."); + } + } + + private boolean containsSensitiveValue(String value) { + return REGISTRATION_NUMBER.matcher(value).find() + || PHONE_NUMBER.matcher(value).find() + || BEARER_TOKEN.matcher(value).find() + || SECRET_ASSIGNMENT.matcher(value).find() + || LABELED_SENSITIVE_VALUE.matcher(value).find(); + } + + private void reject(AiRuntimeFailureCode failureCode, String safeMessage) { + throw new AiRuntimeContractException(failureCode, safeMessage); + } +} diff --git a/src/main/java/com/fowoco/server/aiintegration/application/validation/ValidatingAiRuntimeClient.java b/src/main/java/com/fowoco/server/aiintegration/application/validation/ValidatingAiRuntimeClient.java new file mode 100644 index 0000000..dbd8da6 --- /dev/null +++ b/src/main/java/com/fowoco/server/aiintegration/application/validation/ValidatingAiRuntimeClient.java @@ -0,0 +1,28 @@ +package com.fowoco.server.aiintegration.application.validation; + +import com.fowoco.server.aiintegration.application.model.AiAnalysisRequest; +import com.fowoco.server.aiintegration.application.model.AiAnalysisResponse; +import com.fowoco.server.aiintegration.application.port.AiRuntimeClient; +import java.util.Objects; + +/** + * Mandatory defensive decorator around a Fake or remote AI Runtime transport. + */ +public final class ValidatingAiRuntimeClient implements AiRuntimeClient { + + private final AiRuntimeClient delegate; + private final AiRuntimeContractValidator validator; + + public ValidatingAiRuntimeClient(AiRuntimeClient delegate, AiRuntimeContractValidator validator) { + this.delegate = Objects.requireNonNull(delegate, "delegate must not be null"); + this.validator = Objects.requireNonNull(validator, "validator must not be null"); + } + + @Override + public AiAnalysisResponse analyze(AiAnalysisRequest request) { + validator.validateRequest(request); + AiAnalysisResponse response = delegate.analyze(request); + validator.validateResponse(request, response); + return response; + } +} diff --git a/src/main/java/com/fowoco/server/aiintegration/package-info.java b/src/main/java/com/fowoco/server/aiintegration/package-info.java index 8d11c64..00ec762 100644 --- a/src/main/java/com/fowoco/server/aiintegration/package-info.java +++ b/src/main/java/com/fowoco/server/aiintegration/package-info.java @@ -1,4 +1,8 @@ /** - * AI Runtime client ports, service authentication, and response validation. + * Provider-neutral boundary between the Server and the separately deployed AI Runtime. + * + *

This module owns the internal request/response contract and defensive validation. Prompt + * assembly, model selection, Provider SDKs, and Knowledge content belong to the {@code ai} and + * {@code knowledge} repositories.

*/ package com.fowoco.server.aiintegration; diff --git a/src/test/java/com/fowoco/server/aiintegration/application/model/AiRuntimeOutboundContractTest.java b/src/test/java/com/fowoco/server/aiintegration/application/model/AiRuntimeOutboundContractTest.java new file mode 100644 index 0000000..67240fd --- /dev/null +++ b/src/test/java/com/fowoco/server/aiintegration/application/model/AiRuntimeOutboundContractTest.java @@ -0,0 +1,46 @@ +package com.fowoco.server.aiintegration.application.model; + +import static com.fowoco.server.aiintegration.support.AiRuntimeContractFixture.validRequest; +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +class AiRuntimeOutboundContractTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void outboundJsonContainsOnlyTheExplicitMaskedContract() throws Exception { + JsonNode json = objectMapper.valueToTree(validRequest()); + JsonNode worker = json.get("maskedInput").get("workers").get(0); + + assertThat(json.properties().stream().map(java.util.Map.Entry::getKey).toList()) + .containsExactlyInAnyOrder( + "requestId", + "attemptId", + "contractVersion", + "requiredKnowledgeVersion", + "deadlineMs", + "maskedInput" + ); + assertThat(worker.properties().stream().map(java.util.Map.Entry::getKey).toList()) + .containsExactlyInAnyOrder( + "workerRef", + "preferredLanguage", + "workStatus", + "stayExpiryDate" + ); + assertThat(json.toString().toLowerCase()) + .doesNotContain( + "passportnumber", + "alienregistrationnumber", + "phone", + "accountnumber", + "legalname", + "token", + "authorization" + ); + } +} diff --git a/src/test/java/com/fowoco/server/aiintegration/application/validation/AiRuntimeContractValidatorTest.java b/src/test/java/com/fowoco/server/aiintegration/application/validation/AiRuntimeContractValidatorTest.java new file mode 100644 index 0000000..b51cdde --- /dev/null +++ b/src/test/java/com/fowoco/server/aiintegration/application/validation/AiRuntimeContractValidatorTest.java @@ -0,0 +1,190 @@ +package com.fowoco.server.aiintegration.application.validation; + +import static com.fowoco.server.aiintegration.support.AiRuntimeContractFixture.CONTRACT_VERSION; +import static com.fowoco.server.aiintegration.support.AiRuntimeContractFixture.KNOWLEDGE_VERSION; +import static com.fowoco.server.aiintegration.support.AiRuntimeContractFixture.REQUEST_ID; +import static com.fowoco.server.aiintegration.support.AiRuntimeContractFixture.WORKER_REF; +import static com.fowoco.server.aiintegration.support.AiRuntimeContractFixture.WORKFLOW_ID; +import static com.fowoco.server.aiintegration.support.AiRuntimeContractFixture.responseWithCandidate; +import static com.fowoco.server.aiintegration.support.AiRuntimeContractFixture.validCandidate; +import static com.fowoco.server.aiintegration.support.AiRuntimeContractFixture.validRequest; +import static com.fowoco.server.aiintegration.support.AiRuntimeContractFixture.validResponse; +import static com.fowoco.server.aiintegration.support.AiRuntimeContractFixture.validVersions; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fowoco.server.aiintegration.application.error.AiRuntimeContractException; +import com.fowoco.server.aiintegration.application.error.AiRuntimeFailureCode; +import com.fowoco.server.aiintegration.application.model.AiAnalysisOutcome; +import com.fowoco.server.aiintegration.application.model.AiAnalysisResponse; +import com.fowoco.server.aiintegration.application.model.AiCandidate; +import com.fowoco.server.aiintegration.application.model.AiRuntimeVersions; +import com.fowoco.server.aiintegration.support.AiRuntimeContractFixture; +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +class AiRuntimeContractValidatorTest { + + private final AiRuntimeContractValidator validator = + new AiRuntimeContractValidator(new AiRuntimePrivacyPolicy()); + + @Test + void acceptsValidRequestAndResponse() { + assertThatCode(() -> validator.validateResponse(validRequest(), validResponse())) + .doesNotThrowAnyException(); + } + + @ParameterizedTest + @MethodSource("sensitiveInstructions") + void rejectsSensitiveInstructionBeforeOutboundCall(String instruction) { + assertFailure( + () -> validator.validateRequest(AiRuntimeContractFixture.requestWithInstruction(instruction)), + AiRuntimeFailureCode.SENSITIVE_DATA_REJECTED + ); + } + + static Stream sensitiveInstructions() { + return Stream.of( + "연락처는 010-1234-5678입니다", + "외국인등록번호 990101-5123456", + "passport_number: M12345678", + "Authorization: Bearer secret-token-value", + "api_key=do-not-send-this" + ); + } + + @Test + void rejectsMismatchedRequestIdWithoutLeakingRawResponse() { + AiAnalysisResponse response = new AiAnalysisResponse( + UUID.randomUUID(), + AiAnalysisOutcome.REVIEW_REQUIRED, + validResponse().candidates(), + List.of(), + validVersions(), + 1, + 100 + ); + + assertFailure( + () -> validator.validateResponse(validRequest(), response), + AiRuntimeFailureCode.REQUEST_ID_MISMATCH + ); + } + + @Test + void rejectsContractAndKnowledgeVersionDrift() { + AiRuntimeVersions wrongContract = versions(CONTRACT_VERSION + "-other", KNOWLEDGE_VERSION); + assertFailure( + () -> validator.validateResponse(validRequest(), responseWithVersions(wrongContract)), + AiRuntimeFailureCode.CONTRACT_VERSION_MISMATCH + ); + + AiRuntimeVersions wrongKnowledge = versions(CONTRACT_VERSION, "9.9.9"); + assertFailure( + () -> validator.validateResponse(validRequest(), responseWithVersions(wrongKnowledge)), + AiRuntimeFailureCode.KNOWLEDGE_VERSION_MISMATCH + ); + } + + @Test + void rejectsCandidateOutsideRequestAllowList() { + AiCandidate unknownWorker = new AiCandidate( + "candidate-worker", + UUID.randomUUID(), + WORKFLOW_ID, + Map.of(), + List.of("stay_expiry_date"), + BigDecimal.ONE + ); + assertFailure( + () -> validator.validateResponse(validRequest(), responseWithCandidate(unknownWorker)), + AiRuntimeFailureCode.UNEXPECTED_WORKER_REFERENCE + ); + + AiCandidate unknownWorkflow = new AiCandidate( + "candidate-workflow", + WORKER_REF, + "UNKNOWN_WORKFLOW", + Map.of(), + List.of(), + BigDecimal.ONE + ); + assertFailure( + () -> validator.validateResponse(validRequest(), responseWithCandidate(unknownWorkflow)), + AiRuntimeFailureCode.UNEXPECTED_WORKFLOW + ); + + AiCandidate unknownSlot = new AiCandidate( + "candidate-slot", + WORKER_REF, + WORKFLOW_ID, + Map.of("passport_number", "M12345678"), + List.of(), + BigDecimal.ONE + ); + assertFailure( + () -> validator.validateResponse(validRequest(), responseWithCandidate(unknownSlot)), + AiRuntimeFailureCode.SENSITIVE_DATA_REJECTED + ); + } + + @Test + void rejectsSensitiveCandidateValueAndKeepsExceptionMessageSafe() { + AiCandidate sensitiveCandidate = new AiCandidate( + "candidate-sensitive", + WORKER_REF, + WORKFLOW_ID, + Map.of("contract_end_date", "담당자 전화 010-1234-5678"), + List.of(), + BigDecimal.ONE + ); + + assertThatThrownBy(() -> validator.validateResponse( + validRequest(), + responseWithCandidate(sensitiveCandidate) + )).isInstanceOfSatisfying(AiRuntimeContractException.class, exception -> { + assertThat(exception.failureCode()).isEqualTo(AiRuntimeFailureCode.SENSITIVE_DATA_REJECTED); + assertThat(exception.getMessage()).doesNotContain("010-1234-5678"); + }); + } + + private AiAnalysisResponse responseWithVersions(AiRuntimeVersions versions) { + return new AiAnalysisResponse( + REQUEST_ID, + AiAnalysisOutcome.REVIEW_REQUIRED, + List.of(validCandidate()), + List.of(), + versions, + 1, + 100 + ); + } + + private AiRuntimeVersions versions(String contractVersion, String knowledgeVersion) { + AiRuntimeVersions valid = validVersions(); + return new AiRuntimeVersions( + valid.agentVersion(), + valid.modelProvider(), + valid.modelName(), + valid.modelVersion(), + valid.promptVersion(), + valid.contextPackVersion(), + knowledgeVersion, + contractVersion + ); + } + + private void assertFailure(Runnable invocation, AiRuntimeFailureCode expectedCode) { + assertThatThrownBy(invocation::run) + .isInstanceOfSatisfying(AiRuntimeContractException.class, exception -> + assertThat(exception.failureCode()).isEqualTo(expectedCode) + ); + } +} diff --git a/src/test/java/com/fowoco/server/aiintegration/application/validation/ValidatingAiRuntimeClientTest.java b/src/test/java/com/fowoco/server/aiintegration/application/validation/ValidatingAiRuntimeClientTest.java new file mode 100644 index 0000000..f190237 --- /dev/null +++ b/src/test/java/com/fowoco/server/aiintegration/application/validation/ValidatingAiRuntimeClientTest.java @@ -0,0 +1,38 @@ +package com.fowoco.server.aiintegration.application.validation; + +import static com.fowoco.server.aiintegration.support.AiRuntimeContractFixture.requestWithInstruction; +import static com.fowoco.server.aiintegration.support.AiRuntimeContractFixture.validRequest; +import static com.fowoco.server.aiintegration.support.AiRuntimeContractFixture.validResponse; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fowoco.server.aiintegration.application.error.AiRuntimeContractException; +import com.fowoco.server.aiintegration.support.FakeAiRuntimeClient; +import org.junit.jupiter.api.Test; + +class ValidatingAiRuntimeClientTest { + + private final AiRuntimeContractValidator validator = + new AiRuntimeContractValidator(new AiRuntimePrivacyPolicy()); + + @Test + void validatesBothSidesAndCapturesOneAttemptWithoutTransparentRetry() { + FakeAiRuntimeClient fake = new FakeAiRuntimeClient(); + fake.enqueueResponse(validResponse()); + ValidatingAiRuntimeClient client = new ValidatingAiRuntimeClient(fake, validator); + + assertThat(client.analyze(validRequest())).isEqualTo(validResponse()); + assertThat(fake.receivedRequests()).containsExactly(validRequest()); + } + + @Test + void rejectedInputNeverReachesTransport() { + FakeAiRuntimeClient fake = new FakeAiRuntimeClient(); + fake.enqueueResponse(validResponse()); + ValidatingAiRuntimeClient client = new ValidatingAiRuntimeClient(fake, validator); + + assertThatThrownBy(() -> client.analyze(requestWithInstruction("전화 010-1234-5678"))) + .isInstanceOf(AiRuntimeContractException.class); + assertThat(fake.receivedRequests()).isEmpty(); + } +} diff --git a/src/test/java/com/fowoco/server/aiintegration/support/AiRuntimeContractFixture.java b/src/test/java/com/fowoco/server/aiintegration/support/AiRuntimeContractFixture.java new file mode 100644 index 0000000..f744cde --- /dev/null +++ b/src/test/java/com/fowoco/server/aiintegration/support/AiRuntimeContractFixture.java @@ -0,0 +1,98 @@ +package com.fowoco.server.aiintegration.support; + +import com.fowoco.server.aiintegration.application.model.AiAnalysisOutcome; +import com.fowoco.server.aiintegration.application.model.AiAnalysisRequest; +import com.fowoco.server.aiintegration.application.model.AiAnalysisResponse; +import com.fowoco.server.aiintegration.application.model.AiCandidate; +import com.fowoco.server.aiintegration.application.model.AiRuntimeVersions; +import com.fowoco.server.aiintegration.application.model.MaskedAnalysisInput; +import com.fowoco.server.aiintegration.application.model.MaskedWorkerContext; +import com.fowoco.server.aiintegration.application.model.WorkflowConstraint; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +public final class AiRuntimeContractFixture { + + public static final UUID REQUEST_ID = UUID.fromString("10000000-0000-0000-0000-000000000001"); + public static final UUID ATTEMPT_ID = UUID.fromString("20000000-0000-0000-0000-000000000001"); + public static final UUID WORKER_REF = UUID.fromString("30000000-0000-0000-0000-000000000001"); + public static final String CONTRACT_VERSION = "1.0.0"; + public static final String KNOWLEDGE_VERSION = "0.2.0"; + public static final String WORKFLOW_ID = "EXPIRY_RENEWAL"; + + private AiRuntimeContractFixture() { + } + + public static AiAnalysisRequest validRequest() { + return requestWithInstruction( + "workerRef 30000000-0000-0000-0000-000000000001의 체류연장 준비" + ); + } + + public static AiAnalysisRequest requestWithInstruction(String instruction) { + return new AiAnalysisRequest( + REQUEST_ID, + ATTEMPT_ID, + CONTRACT_VERSION, + KNOWLEDGE_VERSION, + 10_000, + new MaskedAnalysisInput( + instruction, + List.of(new MaskedWorkerContext( + WORKER_REF, + "vi", + "ACTIVE", + LocalDate.of(2026, 12, 31) + )), + List.of(new WorkflowConstraint( + WORKFLOW_ID, + Set.of("stay_expiry_date", "contract_end_date", "monthly_wage") + )) + ) + ); + } + + public static AiAnalysisResponse validResponse() { + return responseWithCandidate(validCandidate()); + } + + public static AiAnalysisResponse responseWithCandidate(AiCandidate candidate) { + return new AiAnalysisResponse( + REQUEST_ID, + AiAnalysisOutcome.REVIEW_REQUIRED, + List.of(candidate), + List.of(), + validVersions(), + 1, + 245 + ); + } + + public static AiCandidate validCandidate() { + return new AiCandidate( + "candidate-1", + WORKER_REF, + WORKFLOW_ID, + Map.of("stay_expiry_date", "2026-12-31"), + List.of("contract_end_date", "monthly_wage"), + new BigDecimal("0.92") + ); + } + + public static AiRuntimeVersions validVersions() { + return new AiRuntimeVersions( + "agent-1.0.0", + "openai", + "gpt-5-mini", + "2026-07-01", + "prompt-3", + "context-0.2.0", + KNOWLEDGE_VERSION, + CONTRACT_VERSION + ); + } +} diff --git a/src/test/java/com/fowoco/server/aiintegration/support/FakeAiRuntimeClient.java b/src/test/java/com/fowoco/server/aiintegration/support/FakeAiRuntimeClient.java new file mode 100644 index 0000000..5e85c5d --- /dev/null +++ b/src/test/java/com/fowoco/server/aiintegration/support/FakeAiRuntimeClient.java @@ -0,0 +1,46 @@ +package com.fowoco.server.aiintegration.support; + +import com.fowoco.server.aiintegration.application.model.AiAnalysisRequest; +import com.fowoco.server.aiintegration.application.model.AiAnalysisResponse; +import com.fowoco.server.aiintegration.application.port.AiRuntimeClient; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; +import java.util.Objects; +import java.util.function.Function; + +/** + * Test-only scripted Runtime that makes no network request. + */ +public final class FakeAiRuntimeClient implements AiRuntimeClient { + + private final Deque> scripts = new ArrayDeque<>(); + private final List receivedRequests = new ArrayList<>(); + + public void enqueueResponse(AiAnalysisResponse response) { + Objects.requireNonNull(response, "response must not be null"); + scripts.addLast(request -> response); + } + + public void enqueueFailure(RuntimeException exception) { + Objects.requireNonNull(exception, "exception must not be null"); + scripts.addLast(request -> { + throw exception; + }); + } + + @Override + public AiAnalysisResponse analyze(AiAnalysisRequest request) { + receivedRequests.add(request); + Function script = scripts.pollFirst(); + if (script == null) { + throw new AssertionError("FakeAiRuntimeClient has no scripted result."); + } + return script.apply(request); + } + + public List receivedRequests() { + return List.copyOf(receivedRequests); + } +}