From 271e5e5cb6dea464e3ed110a84869830472de307 Mon Sep 17 00:00:00 2001 From: hywznn Date: Sun, 26 Jul 2026 20:47:39 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feat(ai):=20AI=20Runtime=20=EC=9B=90?= =?UTF-8?q?=EA=B2=A9=20=ED=98=B8=EC=B6=9C=EA=B3=BC=20=EC=9E=A5=EC=95=A0=20?= =?UTF-8?q?=EA=B2=A9=EB=A6=AC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 표준 Bearer 인증과 요청 추적 헤더를 사용해 versioned internal API를 정확히 한 번 호출합니다. 기본 비활성화 설정, 전체 deadline, 동시 호출 제한, circuit breaker, 응답 크기 및 JSON 검증을 추가합니다. --- .env.example | 12 + .../error/AiRuntimeCallException.java | 29 ++ .../error/AiRuntimeFailureCode.java | 12 +- .../model/AiRuntimeCallContext.java | 27 ++ .../application/port/AiRuntimeClient.java | 7 +- .../validation/ValidatingAiRuntimeClient.java | 5 +- .../http/AiRuntimeCircuitBreaker.java | 82 +++++ .../http/AiRuntimeHttpConfiguration.java | 57 ++++ .../http/AiRuntimeProperties.java | 145 +++++++++ .../http/DisabledAiRuntimeClient.java | 19 ++ .../http/LimitedByteArrayBodyHandler.java | 97 ++++++ .../http/RemoteAiRuntimeClient.java | 290 ++++++++++++++++++ src/main/resources/application.yaml | 10 + .../support/FakeAiRuntimeClient.java | 3 +- 14 files changed, 790 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/fowoco/server/aiintegration/application/error/AiRuntimeCallException.java create mode 100644 src/main/java/com/fowoco/server/aiintegration/application/model/AiRuntimeCallContext.java create mode 100644 src/main/java/com/fowoco/server/aiintegration/infrastructure/http/AiRuntimeCircuitBreaker.java create mode 100644 src/main/java/com/fowoco/server/aiintegration/infrastructure/http/AiRuntimeHttpConfiguration.java create mode 100644 src/main/java/com/fowoco/server/aiintegration/infrastructure/http/AiRuntimeProperties.java create mode 100644 src/main/java/com/fowoco/server/aiintegration/infrastructure/http/DisabledAiRuntimeClient.java create mode 100644 src/main/java/com/fowoco/server/aiintegration/infrastructure/http/LimitedByteArrayBodyHandler.java create mode 100644 src/main/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiRuntimeClient.java diff --git a/.env.example b/.env.example index 8a30db3..d2c8300 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,18 @@ DB_MIGRATION_PASSWORD= # React 개발 서버 또는 배포 Client 주소를 쉼표로 구분합니다. CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173 +# AI Runtime은 fowoco/ai의 versioned internal API가 배포된 환경에서만 켭니다. +# Server는 모델 Provider를 직접 호출하지 않으며, 아래 credential은 환경 Secret으로만 주입합니다. +AI_RUNTIME_ENABLED=false +AI_RUNTIME_ENDPOINT=http://127.0.0.1:8000/internal/v1/analyses +# AI_RUNTIME_SERVICE_CREDENTIAL= +AI_RUNTIME_CONNECT_TIMEOUT=2s +AI_RUNTIME_OVERALL_TIMEOUT=15s +AI_RUNTIME_MAX_RESPONSE_BYTES=1048576 +AI_RUNTIME_MAX_CONCURRENT_CALLS=8 +AI_RUNTIME_CIRCUIT_BREAKER_FAILURE_THRESHOLD=5 +AI_RUNTIME_CIRCUIT_BREAKER_OPEN_DURATION=30s + # Transactional Outbox worker 설정입니다. # 일반 실행에서는 켜 두며, 운영 장애 조사 중 자동 처리를 멈춰야 할 때만 false로 둡니다. OUTBOX_ENABLED=true diff --git a/src/main/java/com/fowoco/server/aiintegration/application/error/AiRuntimeCallException.java b/src/main/java/com/fowoco/server/aiintegration/application/error/AiRuntimeCallException.java new file mode 100644 index 0000000..5f47af5 --- /dev/null +++ b/src/main/java/com/fowoco/server/aiintegration/application/error/AiRuntimeCallException.java @@ -0,0 +1,29 @@ +package com.fowoco.server.aiintegration.application.error; + +import java.util.Objects; + +/** + * Stable transport failure that does not expose a credential, URI, or Runtime response body. + */ +public final class AiRuntimeCallException extends RuntimeException { + + private final AiRuntimeFailureCode failureCode; + + public AiRuntimeCallException(AiRuntimeFailureCode failureCode, String safeMessage) { + super(safeMessage); + this.failureCode = Objects.requireNonNull(failureCode, "failureCode must not be null"); + } + + public AiRuntimeCallException( + AiRuntimeFailureCode failureCode, + String safeMessage, + Throwable cause + ) { + super(safeMessage, cause); + 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 index 4387644..e5b54a6 100644 --- a/src/main/java/com/fowoco/server/aiintegration/application/error/AiRuntimeFailureCode.java +++ b/src/main/java/com/fowoco/server/aiintegration/application/error/AiRuntimeFailureCode.java @@ -12,5 +12,15 @@ public enum AiRuntimeFailureCode { KNOWLEDGE_VERSION_MISMATCH, UNEXPECTED_WORKER_REFERENCE, UNEXPECTED_WORKFLOW, - UNEXPECTED_SLOT + UNEXPECTED_SLOT, + RUNTIME_DISABLED, + BULKHEAD_FULL, + CIRCUIT_OPEN, + DEADLINE_EXCEEDED, + AUTHENTICATION_FAILED, + RATE_LIMITED, + RUNTIME_UNAVAILABLE, + RESPONSE_TOO_LARGE, + RESPONSE_PARSING_FAILED, + TRANSPORT_FAILURE } diff --git a/src/main/java/com/fowoco/server/aiintegration/application/model/AiRuntimeCallContext.java b/src/main/java/com/fowoco/server/aiintegration/application/model/AiRuntimeCallContext.java new file mode 100644 index 0000000..377506d --- /dev/null +++ b/src/main/java/com/fowoco/server/aiintegration/application/model/AiRuntimeCallContext.java @@ -0,0 +1,27 @@ +package com.fowoco.server.aiintegration.application.model; + +import java.util.Locale; +import java.util.regex.Pattern; + +/** + * Transport metadata propagated to the AI Runtime but excluded from the JSON request body. + */ +public record AiRuntimeCallContext(String traceParent) { + + private static final Pattern TRACEPARENT = Pattern.compile( + "^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$" + ); + + public AiRuntimeCallContext { + if (traceParent != null) { + traceParent = traceParent.trim().toLowerCase(Locale.ROOT); + if (!TRACEPARENT.matcher(traceParent).matches()) { + throw new IllegalArgumentException("traceParent must be a valid W3C traceparent value"); + } + } + } + + public static AiRuntimeCallContext withoutTrace() { + return new AiRuntimeCallContext(null); + } +} 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 index c55a03f..e40d062 100644 --- a/src/main/java/com/fowoco/server/aiintegration/application/port/AiRuntimeClient.java +++ b/src/main/java/com/fowoco/server/aiintegration/application/port/AiRuntimeClient.java @@ -2,6 +2,7 @@ import com.fowoco.server.aiintegration.application.model.AiAnalysisRequest; import com.fowoco.server.aiintegration.application.model.AiAnalysisResponse; +import com.fowoco.server.aiintegration.application.model.AiRuntimeCallContext; /** * Server-owned port for one attempt against a separately deployed AI Runtime. @@ -11,5 +12,9 @@ @FunctionalInterface public interface AiRuntimeClient { - AiAnalysisResponse analyze(AiAnalysisRequest request); + AiAnalysisResponse analyze(AiAnalysisRequest request, AiRuntimeCallContext context); + + default AiAnalysisResponse analyze(AiAnalysisRequest request) { + return analyze(request, AiRuntimeCallContext.withoutTrace()); + } } 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 index dbd8da6..6fb8651 100644 --- a/src/main/java/com/fowoco/server/aiintegration/application/validation/ValidatingAiRuntimeClient.java +++ b/src/main/java/com/fowoco/server/aiintegration/application/validation/ValidatingAiRuntimeClient.java @@ -2,6 +2,7 @@ import com.fowoco.server.aiintegration.application.model.AiAnalysisRequest; import com.fowoco.server.aiintegration.application.model.AiAnalysisResponse; +import com.fowoco.server.aiintegration.application.model.AiRuntimeCallContext; import com.fowoco.server.aiintegration.application.port.AiRuntimeClient; import java.util.Objects; @@ -19,9 +20,9 @@ public ValidatingAiRuntimeClient(AiRuntimeClient delegate, AiRuntimeContractVali } @Override - public AiAnalysisResponse analyze(AiAnalysisRequest request) { + public AiAnalysisResponse analyze(AiAnalysisRequest request, AiRuntimeCallContext context) { validator.validateRequest(request); - AiAnalysisResponse response = delegate.analyze(request); + AiAnalysisResponse response = delegate.analyze(request, context); validator.validateResponse(request, response); return response; } diff --git a/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/AiRuntimeCircuitBreaker.java b/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/AiRuntimeCircuitBreaker.java new file mode 100644 index 0000000..fbd2dcc --- /dev/null +++ b/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/AiRuntimeCircuitBreaker.java @@ -0,0 +1,82 @@ +package com.fowoco.server.aiintegration.infrastructure.http; + +import com.fowoco.server.aiintegration.application.error.AiRuntimeCallException; +import com.fowoco.server.aiintegration.application.error.AiRuntimeFailureCode; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; + +/** + * Small consecutive-failure circuit breaker for one AI Runtime endpoint. + */ +final class AiRuntimeCircuitBreaker { + + private final int failureThreshold; + private final Duration openDuration; + private final Clock clock; + + private State state = State.CLOSED; + private int consecutiveFailures; + private Instant reopenAt = Instant.EPOCH; + private boolean halfOpenProbeInFlight; + + AiRuntimeCircuitBreaker(int failureThreshold, Duration openDuration, Clock clock) { + if (failureThreshold < 1) { + throw new IllegalArgumentException("failureThreshold must be positive"); + } + if (openDuration == null || openDuration.isZero() || openDuration.isNegative()) { + throw new IllegalArgumentException("openDuration must be positive"); + } + this.failureThreshold = failureThreshold; + this.openDuration = openDuration; + this.clock = Objects.requireNonNull(clock, "clock must not be null"); + } + + synchronized void beforeCall() { + Instant now = clock.instant(); + if (state == State.OPEN && !now.isBefore(reopenAt)) { + state = State.HALF_OPEN; + halfOpenProbeInFlight = false; + } + if (state == State.OPEN || (state == State.HALF_OPEN && halfOpenProbeInFlight)) { + throw new AiRuntimeCallException( + AiRuntimeFailureCode.CIRCUIT_OPEN, + "AI Runtime circuit is open." + ); + } + if (state == State.HALF_OPEN) { + halfOpenProbeInFlight = true; + } + } + + synchronized void recordSuccess() { + state = State.CLOSED; + consecutiveFailures = 0; + halfOpenProbeInFlight = false; + reopenAt = Instant.EPOCH; + } + + synchronized void recordFailure() { + if (state == State.HALF_OPEN) { + open(); + return; + } + consecutiveFailures++; + if (consecutiveFailures >= failureThreshold) { + open(); + } + } + + private void open() { + state = State.OPEN; + halfOpenProbeInFlight = false; + reopenAt = clock.instant().plus(openDuration); + } + + private enum State { + CLOSED, + OPEN, + HALF_OPEN + } +} diff --git a/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/AiRuntimeHttpConfiguration.java b/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/AiRuntimeHttpConfiguration.java new file mode 100644 index 0000000..9aa4f69 --- /dev/null +++ b/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/AiRuntimeHttpConfiguration.java @@ -0,0 +1,57 @@ +package com.fowoco.server.aiintegration.infrastructure.http; + +import com.fowoco.server.aiintegration.application.port.AiRuntimeClient; +import com.fowoco.server.aiintegration.application.validation.AiRuntimeContractValidator; +import com.fowoco.server.aiintegration.application.validation.ValidatingAiRuntimeClient; +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; + +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(AiRuntimeProperties.class) +public class AiRuntimeHttpConfiguration { + + @Bean + public AiRuntimeClient aiRuntimeClient( + AiRuntimeProperties properties, + AiRuntimeContractValidator validator, + ObjectMapper applicationObjectMapper, + Clock clock + ) { + if (!properties.isEnabled()) { + return new DisabledAiRuntimeClient(); + } + properties.validateEnabledConfiguration(); + + ObjectMapper contractObjectMapper = applicationObjectMapper.rebuild() + .propertyNamingStrategy(PropertyNamingStrategies.LOWER_CAMEL_CASE) + .enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) + .build(); + HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(properties.getConnectTimeout()) + .followRedirects(HttpClient.Redirect.NEVER) + .build(); + AiRuntimeCircuitBreaker circuitBreaker = new AiRuntimeCircuitBreaker( + properties.getCircuitBreakerFailureThreshold(), + properties.getCircuitBreakerOpenDuration(), + clock + ); + AiRuntimeClient remote = new RemoteAiRuntimeClient( + properties.getEndpoint(), + properties.authorizationHeader(), + properties.getOverallTimeout(), + properties.getMaxResponseBytes(), + properties.getMaxConcurrentCalls(), + httpClient, + contractObjectMapper, + circuitBreaker + ); + return new ValidatingAiRuntimeClient(remote, validator); + } +} diff --git a/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/AiRuntimeProperties.java b/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/AiRuntimeProperties.java new file mode 100644 index 0000000..25a703e --- /dev/null +++ b/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/AiRuntimeProperties.java @@ -0,0 +1,145 @@ +package com.fowoco.server.aiintegration.infrastructure.http; + +import java.net.URI; +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "app.ai-runtime") +public final class AiRuntimeProperties { + + private static final int MIN_RESPONSE_BYTES = 1_024; + private static final int MAX_RESPONSE_BYTES = 10 * 1_024 * 1_024; + + private boolean enabled; + private URI endpoint = URI.create("http://127.0.0.1:8000/internal/v1/analyses"); + private String serviceCredential; + private Duration connectTimeout = Duration.ofSeconds(2); + private Duration overallTimeout = Duration.ofSeconds(15); + private int maxResponseBytes = 1_048_576; + private int maxConcurrentCalls = 8; + private int circuitBreakerFailureThreshold = 5; + private Duration circuitBreakerOpenDuration = Duration.ofSeconds(30); + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public URI getEndpoint() { + return endpoint; + } + + public void setEndpoint(URI endpoint) { + this.endpoint = requireHttpEndpoint(endpoint); + } + + public void setServiceCredential(String serviceCredential) { + this.serviceCredential = serviceCredential; + } + + public Duration getConnectTimeout() { + return connectTimeout; + } + + public void setConnectTimeout(Duration connectTimeout) { + this.connectTimeout = requirePositive(connectTimeout, "connectTimeout"); + } + + public Duration getOverallTimeout() { + return overallTimeout; + } + + public void setOverallTimeout(Duration overallTimeout) { + this.overallTimeout = requirePositive(overallTimeout, "overallTimeout"); + } + + public int getMaxResponseBytes() { + return maxResponseBytes; + } + + 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"); + } + this.maxResponseBytes = maxResponseBytes; + } + + public int getMaxConcurrentCalls() { + return maxConcurrentCalls; + } + + public void setMaxConcurrentCalls(int maxConcurrentCalls) { + if (maxConcurrentCalls < 1 || maxConcurrentCalls > 100) { + throw new IllegalArgumentException("maxConcurrentCalls must be between 1 and 100"); + } + this.maxConcurrentCalls = maxConcurrentCalls; + } + + public int getCircuitBreakerFailureThreshold() { + return circuitBreakerFailureThreshold; + } + + public void setCircuitBreakerFailureThreshold(int circuitBreakerFailureThreshold) { + if (circuitBreakerFailureThreshold < 1 || circuitBreakerFailureThreshold > 100) { + throw new IllegalArgumentException("circuitBreakerFailureThreshold must be between 1 and 100"); + } + this.circuitBreakerFailureThreshold = circuitBreakerFailureThreshold; + } + + public Duration getCircuitBreakerOpenDuration() { + return circuitBreakerOpenDuration; + } + + public void setCircuitBreakerOpenDuration(Duration circuitBreakerOpenDuration) { + this.circuitBreakerOpenDuration = requirePositive( + circuitBreakerOpenDuration, + "circuitBreakerOpenDuration" + ); + } + + String authorizationHeader() { + if (serviceCredential == null || serviceCredential.isBlank()) { + throw new IllegalStateException( + "AI_RUNTIME_SERVICE_CREDENTIAL must be configured when AI Runtime is enabled" + ); + } + if (serviceCredential.indexOf('\r') >= 0 || serviceCredential.indexOf('\n') >= 0) { + throw new IllegalStateException("AI Runtime service credential contains an invalid character"); + } + return "Bearer " + serviceCredential.trim(); + } + + void validateEnabledConfiguration() { + requireHttpEndpoint(endpoint); + authorizationHeader(); + requirePositive(connectTimeout, "connectTimeout"); + requirePositive(overallTimeout, "overallTimeout"); + } + + private static URI requireHttpEndpoint(URI value) { + if (value == null + || !value.isAbsolute() + || (!"http".equalsIgnoreCase(value.getScheme()) + && !"https".equalsIgnoreCase(value.getScheme())) + || value.getHost() == null + || value.getUserInfo() != null + || value.getQuery() != null + || value.getFragment() != null) { + throw new IllegalArgumentException("endpoint must be an absolute HTTP(S) URI without credentials or query"); + } + return value; + } + + private static Duration requirePositive(Duration value, String field) { + if (value == null || value.isZero() || value.isNegative()) { + throw new IllegalArgumentException(field + " must be positive"); + } + if (value.compareTo(Duration.ofMillis(100)) < 0) { + throw new IllegalArgumentException(field + " must be at least 100ms"); + } + return value; + } +} diff --git a/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/DisabledAiRuntimeClient.java b/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/DisabledAiRuntimeClient.java new file mode 100644 index 0000000..32b89e3 --- /dev/null +++ b/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/DisabledAiRuntimeClient.java @@ -0,0 +1,19 @@ +package com.fowoco.server.aiintegration.infrastructure.http; + +import com.fowoco.server.aiintegration.application.error.AiRuntimeCallException; +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.AiRuntimeCallContext; +import com.fowoco.server.aiintegration.application.port.AiRuntimeClient; + +final class DisabledAiRuntimeClient implements AiRuntimeClient { + + @Override + public AiAnalysisResponse analyze(AiAnalysisRequest request, AiRuntimeCallContext context) { + throw new AiRuntimeCallException( + AiRuntimeFailureCode.RUNTIME_DISABLED, + "AI Runtime integration is disabled." + ); + } +} diff --git a/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/LimitedByteArrayBodyHandler.java b/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/LimitedByteArrayBodyHandler.java new file mode 100644 index 0000000..9d12d2e --- /dev/null +++ b/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/LimitedByteArrayBodyHandler.java @@ -0,0 +1,97 @@ +package com.fowoco.server.aiintegration.infrastructure.http; + +import java.io.ByteArrayOutputStream; +import java.net.http.HttpResponse; +import java.nio.ByteBuffer; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.Flow; + +/** + * Cancels the HTTP body subscription before an oversized Runtime response is fully buffered. + */ +final class LimitedByteArrayBodyHandler implements HttpResponse.BodyHandler { + + private final int maxBytes; + + LimitedByteArrayBodyHandler(int maxBytes) { + if (maxBytes < 1) { + throw new IllegalArgumentException("maxBytes must be positive"); + } + this.maxBytes = maxBytes; + } + + @Override + public HttpResponse.BodySubscriber apply(HttpResponse.ResponseInfo responseInfo) { + return new LimitedByteArrayBodySubscriber(maxBytes); + } + + static final class ResponseTooLargeException extends RuntimeException { + ResponseTooLargeException() { + super("AI Runtime response exceeded the configured size limit."); + } + } + + private static final class LimitedByteArrayBodySubscriber + implements HttpResponse.BodySubscriber { + + private final int maxBytes; + private final ByteArrayOutputStream output; + private final CompletableFuture body = new CompletableFuture<>(); + private Flow.Subscription subscription; + private int receivedBytes; + + private LimitedByteArrayBodySubscriber(int maxBytes) { + this.maxBytes = maxBytes; + this.output = new ByteArrayOutputStream(Math.min(maxBytes, 8_192)); + } + + @Override + public CompletionStage getBody() { + return body; + } + + @Override + public void onSubscribe(Flow.Subscription subscription) { + if (this.subscription != null) { + subscription.cancel(); + return; + } + this.subscription = subscription; + subscription.request(1); + } + + @Override + public void onNext(List item) { + try { + for (ByteBuffer buffer : item) { + int nextBytes = buffer.remaining(); + if (nextBytes > maxBytes - receivedBytes) { + subscription.cancel(); + body.completeExceptionally(new ResponseTooLargeException()); + return; + } + byte[] chunk = new byte[nextBytes]; + buffer.get(chunk); + output.writeBytes(chunk); + receivedBytes += nextBytes; + } + subscription.request(1); + } catch (RuntimeException exception) { + subscription.cancel(); + body.completeExceptionally(exception); + } + } + + @Override + public void onError(Throwable throwable) { + body.completeExceptionally(throwable); + } + + @Override + public void onComplete() { + body.complete(output.toByteArray()); + } + } +} diff --git a/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiRuntimeClient.java b/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiRuntimeClient.java new file mode 100644 index 0000000..3b35105 --- /dev/null +++ b/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiRuntimeClient.java @@ -0,0 +1,290 @@ +package com.fowoco.server.aiintegration.infrastructure.http; + +import com.fowoco.server.aiintegration.application.error.AiRuntimeCallException; +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.AiRuntimeCallContext; +import com.fowoco.server.aiintegration.application.port.AiRuntimeClient; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpTimeoutException; +import java.time.Duration; +import java.util.EnumSet; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; + +/** + * Exactly-once transport attempt against the separately deployed fowoco/ai Runtime. + */ +public final class RemoteAiRuntimeClient implements AiRuntimeClient { + + private static final String AUTHORIZATION = "Authorization"; + private static final String REQUEST_ID = "X-Request-Id"; + private static final String TRACEPARENT = "traceparent"; + private static final Set CIRCUIT_FAILURES = EnumSet.of( + AiRuntimeFailureCode.DEADLINE_EXCEEDED, + AiRuntimeFailureCode.RATE_LIMITED, + AiRuntimeFailureCode.RUNTIME_UNAVAILABLE, + AiRuntimeFailureCode.RESPONSE_TOO_LARGE, + AiRuntimeFailureCode.RESPONSE_PARSING_FAILED, + AiRuntimeFailureCode.TRANSPORT_FAILURE + ); + + private final URI endpoint; + private final String authorizationHeader; + private final Duration overallTimeout; + private final int maxResponseBytes; + private final HttpClient httpClient; + private final ObjectMapper objectMapper; + private final Semaphore bulkhead; + private final AiRuntimeCircuitBreaker circuitBreaker; + + RemoteAiRuntimeClient( + URI endpoint, + String authorizationHeader, + Duration overallTimeout, + int maxResponseBytes, + int maxConcurrentCalls, + HttpClient httpClient, + ObjectMapper objectMapper, + AiRuntimeCircuitBreaker circuitBreaker + ) { + this.endpoint = Objects.requireNonNull(endpoint, "endpoint must not be null"); + this.authorizationHeader = requireText(authorizationHeader, "authorizationHeader"); + this.overallTimeout = requirePositive(overallTimeout, "overallTimeout"); + if (maxResponseBytes < 1) { + throw new IllegalArgumentException("maxResponseBytes must be positive"); + } + if (maxConcurrentCalls < 1) { + throw new IllegalArgumentException("maxConcurrentCalls must be positive"); + } + this.maxResponseBytes = maxResponseBytes; + this.httpClient = Objects.requireNonNull(httpClient, "httpClient must not be null"); + this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper must not be null"); + this.bulkhead = new Semaphore(maxConcurrentCalls); + this.circuitBreaker = Objects.requireNonNull(circuitBreaker, "circuitBreaker must not be null"); + } + + @Override + public AiAnalysisResponse analyze(AiAnalysisRequest request, AiRuntimeCallContext context) { + Objects.requireNonNull(request, "request must not be null"); + Objects.requireNonNull(context, "context must not be null"); + long startedNanos = System.nanoTime(); + if (!bulkhead.tryAcquire()) { + throw new AiRuntimeCallException( + AiRuntimeFailureCode.BULKHEAD_FULL, + "AI Runtime concurrency limit is full." + ); + } + + boolean circuitPermitAcquired = false; + try { + circuitBreaker.beforeCall(); + circuitPermitAcquired = true; + + long remainingMillis = remainingMillis(request, startedNanos); + AiAnalysisRequest outboundRequest = withRemainingDeadline(request, remainingMillis); + byte[] requestBody = serialize(outboundRequest); + remainingMillis = remainingMillis(request, startedNanos); + + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(endpoint) + .timeout(Duration.ofMillis(remainingMillis)) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .header(AUTHORIZATION, authorizationHeader) + .header(REQUEST_ID, request.requestId().toString()) + .POST(HttpRequest.BodyPublishers.ofByteArray(requestBody)); + if (context.traceParent() != null) { + requestBuilder.header(TRACEPARENT, context.traceParent()); + } + + HttpResponse response = execute(requestBuilder.build(), remainingMillis); + AiAnalysisResponse result = decodeResponse(response); + remainingMillis(request, startedNanos); + circuitBreaker.recordSuccess(); + return result; + } catch (AiRuntimeCallException exception) { + if (circuitPermitAcquired) { + if (CIRCUIT_FAILURES.contains(exception.failureCode())) { + circuitBreaker.recordFailure(); + } else { + circuitBreaker.recordSuccess(); + } + } + throw exception; + } catch (RuntimeException exception) { + if (circuitPermitAcquired) { + circuitBreaker.recordFailure(); + } + throw new AiRuntimeCallException( + AiRuntimeFailureCode.TRANSPORT_FAILURE, + "AI Runtime transport failed.", + exception + ); + } finally { + bulkhead.release(); + } + } + + private HttpResponse execute(HttpRequest request, long timeoutMillis) { + CompletableFuture> future = httpClient.sendAsync( + request, + new LimitedByteArrayBodyHandler(maxResponseBytes) + ); + try { + return future.get(timeoutMillis, TimeUnit.MILLISECONDS); + } catch (TimeoutException exception) { + future.cancel(true); + throw new AiRuntimeCallException( + AiRuntimeFailureCode.DEADLINE_EXCEEDED, + "AI Runtime deadline was exceeded.", + exception + ); + } catch (InterruptedException exception) { + future.cancel(true); + Thread.currentThread().interrupt(); + throw new AiRuntimeCallException( + AiRuntimeFailureCode.TRANSPORT_FAILURE, + "AI Runtime call was interrupted.", + exception + ); + } catch (ExecutionException exception) { + throw classifyExecutionFailure(exception.getCause()); + } + } + + private AiAnalysisResponse decodeResponse(HttpResponse response) { + int status = response.statusCode(); + if (status < 200 || status >= 300) { + throw classifyStatus(status); + } + try { + return objectMapper.readValue(response.body(), AiAnalysisResponse.class); + } catch (JacksonException exception) { + throw new AiRuntimeCallException( + AiRuntimeFailureCode.RESPONSE_PARSING_FAILED, + "AI Runtime response JSON is invalid.", + exception + ); + } + } + + private byte[] serialize(AiAnalysisRequest request) { + try { + return objectMapper.writeValueAsBytes(request); + } catch (JacksonException exception) { + throw new AiRuntimeCallException( + AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT, + "AI Runtime request JSON could not be created.", + exception + ); + } + } + + private AiRuntimeCallException classifyExecutionFailure(Throwable cause) { + Throwable failure = unwrap(cause); + if (failure instanceof HttpTimeoutException + || failure instanceof java.util.concurrent.TimeoutException) { + return new AiRuntimeCallException( + AiRuntimeFailureCode.DEADLINE_EXCEEDED, + "AI Runtime deadline was exceeded.", + failure + ); + } + if (failure instanceof LimitedByteArrayBodyHandler.ResponseTooLargeException) { + return new AiRuntimeCallException( + AiRuntimeFailureCode.RESPONSE_TOO_LARGE, + "AI Runtime response exceeded the configured size limit.", + failure + ); + } + return new AiRuntimeCallException( + AiRuntimeFailureCode.TRANSPORT_FAILURE, + "AI Runtime transport failed.", + failure + ); + } + + private AiRuntimeCallException classifyStatus(int status) { + if (status == 401 || status == 403) { + return new AiRuntimeCallException( + AiRuntimeFailureCode.AUTHENTICATION_FAILED, + "AI Runtime service authentication failed." + ); + } + if (status == 429) { + return new AiRuntimeCallException( + AiRuntimeFailureCode.RATE_LIMITED, + "AI Runtime rate limit was reached." + ); + } + if (status >= 500) { + return new AiRuntimeCallException( + AiRuntimeFailureCode.RUNTIME_UNAVAILABLE, + "AI Runtime is unavailable." + ); + } + return new AiRuntimeCallException( + AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT, + "AI Runtime rejected the request contract." + ); + } + + private long remainingMillis(AiAnalysisRequest request, long startedNanos) { + long configuredMillis = Math.min(request.deadlineMs(), overallTimeout.toMillis()); + long elapsedMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos); + long remainingMillis = configuredMillis - elapsedMillis; + if (remainingMillis < 100) { + throw new AiRuntimeCallException( + AiRuntimeFailureCode.DEADLINE_EXCEEDED, + "AI Runtime deadline was exceeded." + ); + } + return remainingMillis; + } + + private AiAnalysisRequest withRemainingDeadline(AiAnalysisRequest request, long remainingMillis) { + return new AiAnalysisRequest( + request.requestId(), + request.attemptId(), + request.contractVersion(), + request.requiredKnowledgeVersion(), + remainingMillis, + request.maskedInput() + ); + } + + private static Throwable unwrap(Throwable throwable) { + Throwable current = throwable; + while ((current instanceof ExecutionException + || current instanceof java.util.concurrent.CompletionException) + && current.getCause() != null) { + current = current.getCause(); + } + return current; + } + + private static String requireText(String value, String field) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(field + " must not be blank"); + } + return value; + } + + private static Duration requirePositive(Duration value, String field) { + if (value == null || value.isZero() || value.isNegative()) { + throw new IllegalArgumentException(field + " must be positive"); + } + return value; + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index fa70e39..d5db19a 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -37,6 +37,16 @@ springdoc: path: /swagger-ui.html app: + ai-runtime: + enabled: ${AI_RUNTIME_ENABLED:false} + endpoint: ${AI_RUNTIME_ENDPOINT:http://127.0.0.1:8000/internal/v1/analyses} + service-credential: ${AI_RUNTIME_SERVICE_CREDENTIAL:} + connect-timeout: ${AI_RUNTIME_CONNECT_TIMEOUT:2s} + overall-timeout: ${AI_RUNTIME_OVERALL_TIMEOUT:15s} + max-response-bytes: ${AI_RUNTIME_MAX_RESPONSE_BYTES:1048576} + max-concurrent-calls: ${AI_RUNTIME_MAX_CONCURRENT_CALLS:8} + circuit-breaker-failure-threshold: ${AI_RUNTIME_CIRCUIT_BREAKER_FAILURE_THRESHOLD:5} + circuit-breaker-open-duration: ${AI_RUNTIME_CIRCUIT_BREAKER_OPEN_DURATION:30s} reliability: outbox: enabled: ${OUTBOX_ENABLED:true} diff --git a/src/test/java/com/fowoco/server/aiintegration/support/FakeAiRuntimeClient.java b/src/test/java/com/fowoco/server/aiintegration/support/FakeAiRuntimeClient.java index 5e85c5d..2ade3dd 100644 --- a/src/test/java/com/fowoco/server/aiintegration/support/FakeAiRuntimeClient.java +++ b/src/test/java/com/fowoco/server/aiintegration/support/FakeAiRuntimeClient.java @@ -2,6 +2,7 @@ import com.fowoco.server.aiintegration.application.model.AiAnalysisRequest; import com.fowoco.server.aiintegration.application.model.AiAnalysisResponse; +import com.fowoco.server.aiintegration.application.model.AiRuntimeCallContext; import com.fowoco.server.aiintegration.application.port.AiRuntimeClient; import java.util.ArrayDeque; import java.util.ArrayList; @@ -31,7 +32,7 @@ public void enqueueFailure(RuntimeException exception) { } @Override - public AiAnalysisResponse analyze(AiAnalysisRequest request) { + public AiAnalysisResponse analyze(AiAnalysisRequest request, AiRuntimeCallContext context) { receivedRequests.add(request); Function script = scripts.pollFirst(); if (script == null) { From 36db1c7b6b40adb6b61f0fe041e71dbfc38a5463 Mon Sep 17 00:00:00 2001 From: hywznn Date: Sun, 26 Jul 2026 20:47:46 +0900 Subject: [PATCH 02/10] =?UTF-8?q?test(ai):=20AI=20Runtime=20HTTP=20?= =?UTF-8?q?=EA=B3=84=EC=95=BD=EA=B3=BC=20=EC=9E=A5=EC=95=A0=20=EC=8B=9C?= =?UTF-8?q?=EB=82=98=EB=A6=AC=EC=98=A4=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WireMock standalone으로 인증·추적 헤더, camelCase 요청, strict JSON 응답을 검증합니다. 자동 재시도 금지, deadline, 응답 크기, bulkhead와 circuit breaker 동작을 함께 확인합니다. --- build.gradle | 1 + .../http/AiRuntimeCircuitBreakerTest.java | 69 ++++++ .../RemoteAiRuntimeClientWireMockTest.java | 206 ++++++++++++++++++ 3 files changed, 276 insertions(+) create mode 100644 src/test/java/com/fowoco/server/aiintegration/infrastructure/http/AiRuntimeCircuitBreakerTest.java create mode 100644 src/test/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiRuntimeClientWireMockTest.java diff --git a/build.gradle b/build.gradle index c0d931b..da5a982 100644 --- a/build.gradle +++ b/build.gradle @@ -37,6 +37,7 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-security-test' testImplementation 'org.springframework.boot:spring-boot-starter-validation-test' testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' + testImplementation 'org.wiremock:wiremock-standalone:3.13.2' testCompileOnly 'org.projectlombok:lombok' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testAnnotationProcessor 'org.projectlombok:lombok' diff --git a/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/AiRuntimeCircuitBreakerTest.java b/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/AiRuntimeCircuitBreakerTest.java new file mode 100644 index 0000000..7c234f5 --- /dev/null +++ b/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/AiRuntimeCircuitBreakerTest.java @@ -0,0 +1,69 @@ +package com.fowoco.server.aiintegration.infrastructure.http; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fowoco.server.aiintegration.application.error.AiRuntimeCallException; +import com.fowoco.server.aiintegration.application.error.AiRuntimeFailureCode; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import org.junit.jupiter.api.Test; + +class AiRuntimeCircuitBreakerTest { + + @Test + void allowsOneHalfOpenProbeAndClosesAfterSuccess() { + MutableClock clock = new MutableClock(); + AiRuntimeCircuitBreaker breaker = new AiRuntimeCircuitBreaker( + 1, + Duration.ofSeconds(30), + clock + ); + breaker.beforeCall(); + breaker.recordFailure(); + + assertCircuitOpen(breaker); + clock.advance(Duration.ofSeconds(30)); + + breaker.beforeCall(); + assertCircuitOpen(breaker); + breaker.recordSuccess(); + breaker.beforeCall(); + breaker.recordSuccess(); + } + + private void assertCircuitOpen(AiRuntimeCircuitBreaker breaker) { + assertThatThrownBy(breaker::beforeCall) + .isInstanceOfSatisfying( + AiRuntimeCallException.class, + exception -> assertThat(exception.failureCode()) + .isEqualTo(AiRuntimeFailureCode.CIRCUIT_OPEN) + ); + } + + private static final class MutableClock extends Clock { + + private Instant instant = Instant.parse("2026-07-26T00:00:00Z"); + + void advance(Duration duration) { + instant = instant.plus(duration); + } + + @Override + public ZoneId getZone() { + return ZoneId.of("UTC"); + } + + @Override + public Clock withZone(ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return instant; + } + } +} diff --git a/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiRuntimeClientWireMockTest.java b/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiRuntimeClientWireMockTest.java new file mode 100644 index 0000000..7d30f40 --- /dev/null +++ b/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiRuntimeClientWireMockTest.java @@ -0,0 +1,206 @@ +package com.fowoco.server.aiintegration.infrastructure.http; + +import static com.fowoco.server.aiintegration.support.AiRuntimeContractFixture.REQUEST_ID; +import static com.fowoco.server.aiintegration.support.AiRuntimeContractFixture.validRequest; +import static com.fowoco.server.aiintegration.support.AiRuntimeContractFixture.validResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.absent; +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.exactly; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fowoco.server.aiintegration.application.error.AiRuntimeCallException; +import com.fowoco.server.aiintegration.application.error.AiRuntimeFailureCode; +import com.fowoco.server.aiintegration.application.model.AiAnalysisResponse; +import com.fowoco.server.aiintegration.application.model.AiRuntimeCallContext; +import com.fowoco.server.aiintegration.application.port.AiRuntimeClient; +import com.github.tomakehurst.wiremock.WireMockServer; +import java.net.URI; +import java.net.http.HttpClient; +import java.time.Clock; +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.PropertyNamingStrategies; + +class RemoteAiRuntimeClientWireMockTest { + + private static final String PATH = "/internal/v1/analyses"; + private static final String TRACEPARENT = + "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"; + + private final ObjectMapper objectMapper = new ObjectMapper().rebuild() + .propertyNamingStrategy(PropertyNamingStrategies.LOWER_CAMEL_CASE) + .enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) + .build(); + + private WireMockServer wireMock; + + @BeforeEach + void startWireMock() { + wireMock = new WireMockServer(wireMockConfig().dynamicPort()); + wireMock.start(); + } + + @AfterEach + void stopWireMock() { + if (wireMock != null) { + wireMock.stop(); + } + } + + @Test + void sendsCanonicalHeadersAndCamelCaseBodyThenParsesResponse() throws Exception { + wireMock.stubFor(post(urlEqualTo(PATH)) + .withHeader("Authorization", equalTo("Bearer test-service-credential")) + .withHeader("X-Request-Id", equalTo(REQUEST_ID.toString())) + .withHeader("traceparent", equalTo(TRACEPARENT)) + .withRequestBody(com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath( + "$.requestId", + equalTo(REQUEST_ID.toString()) + )) + .withRequestBody(com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath( + "$.maskedInput.workers[0].stayExpiryDate" + )) + .withRequestBody(com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath( + "$.request_id", + absent() + )) + .willReturn(jsonResponse(objectMapper.writeValueAsString(validResponse())))); + + AiAnalysisResponse response = client(1_048_576, 8, 5, Duration.ofSeconds(30)) + .analyze(validRequest(), new AiRuntimeCallContext(TRACEPARENT)); + + assertThat(response).isEqualTo(validResponse()); + wireMock.verify(exactly(1), postRequestedFor(urlEqualTo(PATH))); + } + + @Test + void doesNotRetryAndOpensCircuitAfterConsecutiveRuntimeFailures() { + wireMock.stubFor(post(urlEqualTo(PATH)).willReturn(aResponse().withStatus(503))); + AiRuntimeClient client = client(1_048_576, 8, 2, Duration.ofSeconds(30)); + + assertFailureCode(client, AiRuntimeFailureCode.RUNTIME_UNAVAILABLE); + assertFailureCode(client, AiRuntimeFailureCode.RUNTIME_UNAVAILABLE); + assertFailureCode(client, AiRuntimeFailureCode.CIRCUIT_OPEN); + + wireMock.verify(exactly(2), postRequestedFor(urlEqualTo(PATH))); + } + + @Test + void rejectsUnknownResponseFieldWithStableParsingFailure() throws Exception { + String response = objectMapper.writeValueAsString(validResponse()); + String responseWithUnknownField = response.substring(0, response.length() - 1) + + ",\"unexpected\":\"value\"}"; + wireMock.stubFor(post(urlEqualTo(PATH)).willReturn(jsonResponse(responseWithUnknownField))); + + assertFailureCode( + client(1_048_576, 8, 5, Duration.ofSeconds(30)), + AiRuntimeFailureCode.RESPONSE_PARSING_FAILED + ); + } + + @Test + void enforcesOverallDeadline() { + wireMock.stubFor(post(urlEqualTo(PATH)) + .willReturn(jsonResponse("{}").withFixedDelay(500))); + + AiRuntimeClient client = client( + 1_048_576, + 8, + 5, + Duration.ofMillis(200) + ); + + assertFailureCode(client, AiRuntimeFailureCode.DEADLINE_EXCEEDED); + wireMock.verify(exactly(1), postRequestedFor(urlEqualTo(PATH))); + } + + @Test + void cancelsOversizedResponseBody() { + wireMock.stubFor(post(urlEqualTo(PATH)) + .willReturn(jsonResponse("\"" + "x".repeat(2_000) + "\""))); + + assertFailureCode( + client(1_024, 8, 5, Duration.ofSeconds(30)), + AiRuntimeFailureCode.RESPONSE_TOO_LARGE + ); + } + + @Test + void rejectsConcurrentCallWhenBulkheadIsFull() throws Exception { + wireMock.stubFor(post(urlEqualTo(PATH)) + .willReturn(jsonResponse(objectMapper.writeValueAsString(validResponse())) + .withFixedDelay(500))); + AiRuntimeClient client = client(1_048_576, 1, 5, Duration.ofSeconds(30)); + + CompletableFuture first = CompletableFuture.supplyAsync( + () -> client.analyze(validRequest()) + ); + awaitReceivedRequest(); + + assertFailureCode(client, AiRuntimeFailureCode.BULKHEAD_FULL); + assertThat(first.get(2, TimeUnit.SECONDS)).isEqualTo(validResponse()); + wireMock.verify(exactly(1), postRequestedFor(urlEqualTo(PATH))); + } + + private RemoteAiRuntimeClient client( + int maxResponseBytes, + int maxConcurrentCalls, + int failureThreshold, + Duration overallTimeout + ) { + HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(1)) + .followRedirects(HttpClient.Redirect.NEVER) + .build(); + return new RemoteAiRuntimeClient( + URI.create(wireMock.baseUrl() + PATH), + "Bearer test-service-credential", + overallTimeout, + maxResponseBytes, + maxConcurrentCalls, + httpClient, + objectMapper, + new AiRuntimeCircuitBreaker( + failureThreshold, + Duration.ofSeconds(30), + Clock.systemUTC() + ) + ); + } + + private void assertFailureCode(AiRuntimeClient client, AiRuntimeFailureCode expected) { + assertThatThrownBy(() -> client.analyze(validRequest())) + .isInstanceOfSatisfying( + AiRuntimeCallException.class, + exception -> assertThat(exception.failureCode()).isEqualTo(expected) + ); + } + + private void awaitReceivedRequest() throws InterruptedException { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(1); + while (wireMock.getAllServeEvents().isEmpty() && System.nanoTime() < deadline) { + Thread.sleep(10); + } + assertThat(wireMock.getAllServeEvents()).hasSize(1); + } + + private com.github.tomakehurst.wiremock.client.ResponseDefinitionBuilder jsonResponse(String body) { + return aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(body); + } +} From 80ad577d49920e4361f598a3654c4b5532ea0683 Mon Sep 17 00:00:00 2001 From: hywznn Date: Sun, 26 Jul 2026 23:26:58 +0900 Subject: [PATCH 03/10] =?UTF-8?q?docs(ai):=20AI=20Runtime=20HTTP=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20=EC=9A=B4=EC=98=81=20=EA=B8=B0=EC=A4=80=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 초보자도 설정과 장애 코드를 이해할 수 있도록 기본 비활성화 이유, 배포 순서, 오류별 대응을 문서화합니다. ADR에 맞춰 표준 Authorization Bearer 인증과 요청 추적 규칙을 명시합니다. --- docs/ai-runtime-contract.md | 91 ++++++++++++++++++++++++++++++++----- 1 file changed, 79 insertions(+), 12 deletions(-) diff --git a/docs/ai-runtime-contract.md b/docs/ai-runtime-contract.md index 6d89c86..3efef89 100644 --- a/docs/ai-runtime-contract.md +++ b/docs/ai-runtime-contract.md @@ -3,9 +3,9 @@ 이 문서는 `fowoco/server`가 별도 배포되는 `fowoco/ai` Runtime을 호출할 때 지켜야 하는 최소 계약과 방어 규칙을 설명합니다. -현재 단계는 **HTTP 연결 전 계약 기반**입니다. 실제 `/internal/v1/analyses` OpenAPI와 -Structured Output JSON Schema의 원본은 `fowoco/ai`가 소유하며, 원본 계약이 release되면 -Server의 `RemoteAiRuntimeClient`와 fixture를 그 version에 맞춰 연결합니다. +Server에는 `/internal/v1/analyses`를 호출하는 HTTP Adapter까지 구현되어 있습니다. +다만 실제 OpenAPI와 Structured Output JSON Schema의 원본은 `fowoco/ai`가 소유하므로, +AI 저장소에서 같은 `contractVersion`을 release하기 전까지 실제 호출은 기본적으로 꺼 둡니다. ## 초보자용 한 줄 설명 @@ -19,7 +19,10 @@ AiRunWorker (#24, 후속) 2. AiRuntimeClient transport를 정확히 한 번 호출 3. 응답 ID·version·worker·workflow·slot 재검사 → FakeAiRuntimeClient (test) - → RemoteAiRuntimeClient (#8 후속) + → RemoteAiRuntimeClient + 1. deadline·bulkhead·circuit breaker 적용 + 2. Bearer 인증과 추적 header 전달 + 3. 응답 크기 제한과 strict JSON parsing → POST /internal/v1/analyses (fowoco/ai) ``` @@ -128,16 +131,80 @@ Worker Link token을 추가하지 않습니다. - `FakeAiRuntimeClient`: `src/test`에만 있으며 응답이나 예외를 순서대로 예약합니다. - `ValidatingAiRuntimeClient`: transport 앞뒤에서 같은 방어 검증을 수행합니다. -- `RemoteAiRuntimeClient`: 아직 없습니다. AI 원본 계약 release 후 추가합니다. +- `RemoteAiRuntimeClient`: 설정이 켜진 환경에서만 AI Runtime을 HTTP로 한 번 호출합니다. +- `DisabledAiRuntimeClient`: 기본 구현이며, 실수로 호출하면 `RUNTIME_DISABLED`로 즉시 + 실패합니다. LM Studio나 모델 Provider로 우회하지 않습니다. -실제 HTTP 연결 PR에서는 다음을 추가로 검증합니다. +WireMock 계약 테스트는 다음 동작을 검증합니다. -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 통합 테스트 +1. `Authorization: Bearer `, `X-Request-Id`, `traceparent` 전달 +2. 문서와 같은 camelCase 요청 JSON 사용 +3. 알 수 없는 JSON field와 제한보다 큰 응답 거부 +4. connect timeout과 요청·응답 전체 deadline +5. circuit breaker와 동시 호출 수 bulkhead +6. HTTP·parsing·contract 오류의 안정적인 `AiRuntimeFailureCode` 분류 +7. 실패 응답에도 HTTP 요청이 한 번만 발생하는지 확인 Remote Client는 자동 HTTP retry를 하지 않습니다. 다시 호출하려면 #24가 먼저 새로운 AiAttempt를 DB에 기록해야 합니다. + +## 실행 설정 + +평소 local 실행과 아직 AI 계약이 배포되지 않은 환경에서는 아래 기본값을 유지합니다. + +```dotenv +AI_RUNTIME_ENABLED=false +``` + +AI Runtime 계약이 배포된 통합 환경에서는 배포 Secret과 함께 설정합니다. + +```dotenv +AI_RUNTIME_ENABLED=true +AI_RUNTIME_ENDPOINT=https://ai.example.com/internal/v1/analyses +AI_RUNTIME_SERVICE_CREDENTIAL=<배포 환경 Secret> +``` + +`AI_RUNTIME_SERVICE_CREDENTIAL`은 Git, 로그, 오류 응답에 남기지 않습니다. Server가 표준 +`Authorization: Bearer ...` 형식으로 조립합니다. `X-Request-Id`는 분석 요청의 +`requestId`와 같고, 상위 요청의 유효한 W3C `traceparent`가 있으면 그대로 전달합니다. + +| 설정 | 기본값 | 의미 | +| --- | --- | --- | +| `AI_RUNTIME_CONNECT_TIMEOUT` | `2s` | AI 서버에 TCP 연결을 맺을 수 있는 최대 시간 | +| `AI_RUNTIME_OVERALL_TIMEOUT` | `15s` | 연결·요청·응답 수신 전체의 Server 상한 | +| `AI_RUNTIME_MAX_RESPONSE_BYTES` | `1048576` | 응답을 메모리에 받기 전 적용하는 최대 크기 | +| `AI_RUNTIME_MAX_CONCURRENT_CALLS` | `8` | Server 한 인스턴스가 동시에 보내는 최대 호출 수 | +| `AI_RUNTIME_CIRCUIT_BREAKER_FAILURE_THRESHOLD` | `5` | 연속 장애 후 호출을 잠시 막는 기준 | +| `AI_RUNTIME_CIRCUIT_BREAKER_OPEN_DURATION` | `30s` | 차단 후 시험 호출까지 기다리는 시간 | + +요청의 `deadlineMs`와 `AI_RUNTIME_OVERALL_TIMEOUT` 중 더 짧은 값을 사용합니다. 따라서 +상위 AiRun이 허용한 시간보다 오래 기다리지 않습니다. + +## 장애가 발생하면 + +| 상황 | 안전한 실패 코드 | 처리 방향 | +| --- | --- | --- | +| 기능 비활성화 | `RUNTIME_DISABLED` | 설정과 AI 계약 release 확인 | +| 동시 호출 한도 초과 | `BULKHEAD_FULL` | #24가 새 AiAttempt로 재시도 여부 결정 | +| 회로 차단 중 | `CIRCUIT_OPEN` | Runtime 복구 대기 | +| 전체 제한시간 초과 | `DEADLINE_EXCEEDED` | 자동 재시도하지 않음 | +| 서비스 인증 실패 | `AUTHENTICATION_FAILED` | 배포 Secret과 audience/scope 확인 | +| `429` | `RATE_LIMITED` | Runtime 정책 확인 후 명시적 재시도 | +| `5xx` | `RUNTIME_UNAVAILABLE` | Runtime 상태 확인 | +| 큰 응답 | `RESPONSE_TOO_LARGE` | 계약과 응답 크기 조사 | +| 잘못된 JSON | `RESPONSE_PARSING_FAILED` | contract version과 schema 조사 | +| 네트워크 오류 | `TRANSPORT_FAILURE` | DNS·TLS·네트워크 상태 확인 | + +오류 메시지에는 credential, endpoint query, 응답 원문을 넣지 않습니다. #24는 안전한 실패 +코드, `requestId`, version, latency만 AiAttempt 진단값으로 저장합니다. + +## AI 저장소와 연결하는 순서 + +1. `fowoco/ai`가 `/internal/v1/analyses` OpenAPI와 JSON Schema를 versioned release로 냅니다. +2. Server의 camelCase fixture와 AI 원본 계약이 같은지 consumer contract test로 확인합니다. +3. staging에 service credential과 endpoint를 Secret으로 주입합니다. +4. 정상·`401`·`429`·`5xx`·timeout smoke test를 통과시킵니다. +5. 그 후에만 `AI_RUNTIME_ENABLED=true`를 적용합니다. + +계약이 다르면 임시 필드나 호환되지 않는 JSON을 Server에 추가하지 않고, 양쪽 저장소에서 +`contractVersion`을 합의한 다음 fixture를 함께 갱신합니다. From eae5731fee7a525ea1e261db1b0c95809a7e2e0f Mon Sep 17 00:00:00 2001 From: hywznn Date: Mon, 27 Jul 2026 00:26:31 +0900 Subject: [PATCH 04/10] =?UTF-8?q?fix(ai):=20Server=20=EA=B8=B0=EC=A4=80=20?= =?UTF-8?q?=EC=B2=B4=EB=A5=98=EB=A7=8C=EB=A3=8C=EC=9D=BC=20=EB=B3=B4?= =?UTF-8?q?=EC=A1=B4=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI 후보가 요청에 포함된 stay_expiry_date를 다른 값으로 바꾸면 CORE_VALUE_MISMATCH로 거부합니다. --- .../error/AiRuntimeFailureCode.java | 1 + .../AiRuntimeContractValidator.java | 26 +++++++++++++++---- .../AiRuntimeContractValidatorTest.java | 17 ++++++++++++ 3 files changed, 39 insertions(+), 5 deletions(-) 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 index e5b54a6..d261ed7 100644 --- a/src/main/java/com/fowoco/server/aiintegration/application/error/AiRuntimeFailureCode.java +++ b/src/main/java/com/fowoco/server/aiintegration/application/error/AiRuntimeFailureCode.java @@ -13,6 +13,7 @@ public enum AiRuntimeFailureCode { UNEXPECTED_WORKER_REFERENCE, UNEXPECTED_WORKFLOW, UNEXPECTED_SLOT, + CORE_VALUE_MISMATCH, RUNTIME_DISABLED, BULKHEAD_FULL, CIRCUIT_OPEN, 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 index d1fcf60..4be3fb1 100644 --- a/src/main/java/com/fowoco/server/aiintegration/application/validation/AiRuntimeContractValidator.java +++ b/src/main/java/com/fowoco/server/aiintegration/application/validation/AiRuntimeContractValidator.java @@ -72,9 +72,11 @@ public void validateResponse(AiAnalysisRequest request, AiAnalysisResponse respo } Map> allowedSlotsByWorkflow = allowedSlotsByWorkflow(request); - Set allowedWorkers = request.maskedInput().workers().stream() - .map(MaskedWorkerContext::workerRef) - .collect(java.util.stream.Collectors.toUnmodifiableSet()); + Map allowedWorkers = request.maskedInput().workers().stream() + .collect(java.util.stream.Collectors.toUnmodifiableMap( + MaskedWorkerContext::workerRef, + worker -> worker + )); Set candidateRefs = new HashSet<>(); response.candidates().forEach(candidate -> validateCandidate(candidate, allowedWorkers, allowedSlotsByWorkflow, candidateRefs)); @@ -147,14 +149,15 @@ private void validateResponseVersions(AiAnalysisRequest request, AiRuntimeVersio private void validateCandidate( AiCandidate candidate, - Set allowedWorkers, + Map 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())) { + MaskedWorkerContext worker = allowedWorkers.get(candidate.workerRef()); + if (worker == null) { reject(AiRuntimeFailureCode.UNEXPECTED_WORKER_REFERENCE, "AI Runtime returned an unknown worker reference."); } Set allowedSlots = allowedSlotsByWorkflow.get(candidate.workflowId()); @@ -169,6 +172,7 @@ private void validateCandidate( validateAllowedSlot(key, allowedSlots); privacyPolicy.validateText(value, 4_000, true); }); + validateCoreValues(worker, candidate); Set missingSlots = new HashSet<>(); candidate.missingSlots().forEach(slot -> { validateAllowedSlot(slot, allowedSlots); @@ -178,6 +182,18 @@ private void validateCandidate( }); } + private void validateCoreValues(MaskedWorkerContext worker, AiCandidate candidate) { + String returnedStayExpiryDate = candidate.extractedSlots().get("stay_expiry_date"); + if (returnedStayExpiryDate != null + && worker.stayExpiryDate() != null + && !worker.stayExpiryDate().toString().equals(returnedStayExpiryDate)) { + reject( + AiRuntimeFailureCode.CORE_VALUE_MISMATCH, + "AI Runtime changed a Server-owned core value." + ); + } + } + private void validateAllowedSlot(String slot, Set allowedSlots) { privacyPolicy.validateKey(slot); validateIdentifier(slot, AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT); 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 index b51cdde..5449b6a 100644 --- a/src/test/java/com/fowoco/server/aiintegration/application/validation/AiRuntimeContractValidatorTest.java +++ b/src/test/java/com/fowoco/server/aiintegration/application/validation/AiRuntimeContractValidatorTest.java @@ -155,6 +155,23 @@ void rejectsSensitiveCandidateValueAndKeepsExceptionMessageSafe() { }); } + @Test + void rejectsCandidateThatChangesServerOwnedStayExpiryDate() { + AiCandidate changedDate = new AiCandidate( + "candidate-changed-date", + WORKER_REF, + WORKFLOW_ID, + Map.of("stay_expiry_date", "2099-01-01"), + List.of("contract_end_date", "monthly_wage"), + BigDecimal.ONE + ); + + assertFailure( + () -> validator.validateResponse(validRequest(), responseWithCandidate(changedDate)), + AiRuntimeFailureCode.CORE_VALUE_MISMATCH + ); + } + private AiAnalysisResponse responseWithVersions(AiRuntimeVersions versions) { return new AiAnalysisResponse( REQUEST_ID, From 231d5ebd599ab47a1c14a25098d587d9b526bb35 Mon Sep 17 00:00:00 2001 From: hywznn Date: Mon, 3 Aug 2026 11:49:41 +0900 Subject: [PATCH 05/10] =?UTF-8?q?fix(ai):=20Runtime=20408=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=EC=9D=84=20=EC=A0=9C=ED=95=9C=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EC=B4=88=EA=B3=BC=EB=A1=9C=20=EB=B6=84=EB=A5=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리뷰 의견에 따라 HTTP 408을 요청 계약 오류가 아닌 DEADLINE_EXCEEDED로 변환하고 WireMock 회귀 테스트를 추가합니다. --- docs/ai-runtime-contract.md | 2 +- .../infrastructure/http/RemoteAiRuntimeClient.java | 6 ++++++ .../http/RemoteAiRuntimeClientWireMockTest.java | 11 +++++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/docs/ai-runtime-contract.md b/docs/ai-runtime-contract.md index 40ed0e8..ab09e52 100644 --- a/docs/ai-runtime-contract.md +++ b/docs/ai-runtime-contract.md @@ -203,7 +203,7 @@ AI_RUNTIME_SERVICE_CREDENTIAL=<배포 환경 Secret> | 기능 비활성화 | `RUNTIME_DISABLED` | 설정과 AI 계약 release 확인 | | 동시 호출 한도 초과 | `BULKHEAD_FULL` | #24가 새 AiAttempt로 재시도 여부 결정 | | 회로 차단 중 | `CIRCUIT_OPEN` | Runtime 복구 대기 | -| 전체 제한시간 초과 | `DEADLINE_EXCEEDED` | 자동 재시도하지 않음 | +| 전체 제한시간 초과 또는 Runtime `408` | `DEADLINE_EXCEEDED` | 자동 재시도하지 않음 | | 서비스 인증 실패 | `AUTHENTICATION_FAILED` | 배포 Secret과 audience/scope 확인 | | `429` | `RATE_LIMITED` | Runtime 정책 확인 후 명시적 재시도 | | `5xx` | `RUNTIME_UNAVAILABLE` | Runtime 상태 확인 | diff --git a/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiRuntimeClient.java b/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiRuntimeClient.java index 8f606d0..7ccc578 100644 --- a/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiRuntimeClient.java +++ b/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiRuntimeClient.java @@ -216,6 +216,12 @@ private AiRuntimeCallException classifyExecutionFailure(Throwable cause) { } private AiRuntimeCallException classifyStatus(int status) { + if (status == 408) { + return new AiRuntimeCallException( + AiRuntimeFailureCode.DEADLINE_EXCEEDED, + "AI Runtime deadline was exceeded." + ); + } if (status == 401 || status == 403) { return new AiRuntimeCallException( AiRuntimeFailureCode.AUTHENTICATION_FAILED, diff --git a/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiRuntimeClientWireMockTest.java b/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiRuntimeClientWireMockTest.java index 216b7a7..6785bc6 100644 --- a/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiRuntimeClientWireMockTest.java +++ b/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiRuntimeClientWireMockTest.java @@ -131,6 +131,17 @@ void enforcesOverallDeadline() { wireMock.verify(exactly(1), postRequestedFor(urlEqualTo(PATH))); } + @Test + void mapsRuntimeRequestTimeoutToDeadlineExceeded() { + wireMock.stubFor(post(urlEqualTo(PATH)).willReturn(aResponse().withStatus(408))); + + assertFailureCode( + client(1_048_576, 8, 5, Duration.ofSeconds(30)), + AiRuntimeFailureCode.DEADLINE_EXCEEDED + ); + wireMock.verify(exactly(1), postRequestedFor(urlEqualTo(PATH))); + } + @Test void cancelsOversizedResponseBody() { wireMock.stubFor(post(urlEqualTo(PATH)) From 6af9bb652698060f6dcfa1f8225ccc1d9e76e918 Mon Sep 17 00:00:00 2001 From: hywznn Date: Mon, 3 Aug 2026 15:21:05 +0900 Subject: [PATCH 06/10] =?UTF-8?q?feat(ai):=20PLAN=C2=B7ANALYZE=20=ED=86=B5?= =?UTF-8?q?=EC=8B=A0=20=EA=B3=84=EC=95=BD=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ai-runtime-contract.md | 94 ++++++++++-- .../application/model/AiAnalysisOutcome.java | 1 + .../application/model/AiAnalysisPhase.java | 9 ++ .../application/model/AiAnalysisRequest.java | 4 +- .../application/model/AiAnalysisResponse.java | 4 + .../model/AiContextRequirement.java | 28 ++++ .../application/model/AiQuestion.java | 17 +++ .../application/model/AnalysisInput.java | 4 +- .../AiRuntimeContractValidator.java | 134 ++++++++++++++++-- .../http/RemoteAiRuntimeClient.java | 1 + .../model/AiRuntimeOutboundContractTest.java | 22 +++ .../AiRuntimeContractValidatorTest.java | 59 ++++++++ .../ValidatingAiRuntimeClientTest.java | 14 ++ .../RemoteAiRuntimeClientWireMockTest.java | 34 +++++ .../support/AiRuntimeContractFixture.java | 61 ++++++++ 15 files changed, 463 insertions(+), 23 deletions(-) create mode 100644 src/main/java/com/fowoco/server/aiintegration/application/model/AiAnalysisPhase.java create mode 100644 src/main/java/com/fowoco/server/aiintegration/application/model/AiContextRequirement.java create mode 100644 src/main/java/com/fowoco/server/aiintegration/application/model/AiQuestion.java diff --git a/docs/ai-runtime-contract.md b/docs/ai-runtime-contract.md index ab09e52..e6a76d9 100644 --- a/docs/ai-runtime-contract.md +++ b/docs/ai-runtime-contract.md @@ -29,17 +29,81 @@ AiRunWorker (#24, 후속) `AiRuntimeClient`는 OpenAI, Gemini, Anthropic 같은 Provider를 직접 호출하지 않습니다. Prompt, Agent Pipeline, Provider retry와 모델 선택은 `fowoco/ai` 책임입니다. -## 요청 계약 +## PLAN 요청 계약 + +첫 호출은 HR 발화문을 이해하고 Server에 필요한 DB field를 요청하는 단계입니다. 화면의 +빠른 선택 태그는 `intentHint`에 넣지만 참고 정보일 뿐이며, 최종 분류 결과는 Runtime이 +`detectedIntent`로 반환합니다. 이 단계에는 Worker UUID나 DB 조회값을 넣지 않습니다. ```json { "requestId": "10000000-0000-0000-0000-000000000001", "attemptId": "20000000-0000-0000-0000-000000000001", + "phase": "PLAN", "contractVersion": "1.0.0", "requiredKnowledgeVersion": "0.2.0", "deadlineMs": 10000, "analysisInput": { - "instruction": "가상 근로자 응웬반안(010-1234-5678)의 체류연장 준비", + "instruction": "응웬반안 체류연장 준비해줘", + "intentHint": "EXPIRY_RENEWAL", + "workers": [], + "workflowConstraints": [] + } +} +``` + +Runtime이 DB 정보를 더 필요로 하면 성공 응답으로 `CONTEXT_REQUIRED`를 반환합니다. +Agent는 SQL을 만들거나 DB를 직접 조회하지 않고, canonical field key만 요청합니다. + +```json +{ + "requestId": "10000000-0000-0000-0000-000000000001", + "outcome": "CONTEXT_REQUIRED", + "contextRequirement": { + "detectedIntent": "EXPIRY_RENEWAL", + "confidence": 0.94, + "targetDisplayName": "응웬반안", + "extractedSlots": {}, + "requiredFieldKeys": [ + "legal_name", + "stay_expiry_date" + ] + }, + "questions": [], + "candidates": [], + "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": 120 +} +``` + +## ANALYZE 요청 계약 + +#74가 `targetDisplayName`을 현재 사업장 안에서 한 명의 Worker로 찾고, 허용된 +`requiredFieldKeys`만 Repository로 조회합니다. 그 결과를 넣어 새로운 `attemptId`로 +ANALYZE를 호출합니다. MVP에서는 한 요청에 Worker 한 명만 허용합니다. + +```json +{ + "requestId": "10000000-0000-0000-0000-000000000001", + "attemptId": "20000000-0000-0000-0000-000000000002", + "phase": "ANALYZE", + "contractVersion": "1.0.0", + "requiredKnowledgeVersion": "0.2.0", + "deadlineMs": 10000, + "analysisInput": { + "instruction": "응웬반안 체류연장 준비해줘", + "intentHint": "EXPIRY_RENEWAL", "workers": [ { "workerRef": "30000000-0000-0000-0000-000000000001", @@ -52,9 +116,7 @@ Prompt, Agent Pipeline, Provider retry와 모델 선택은 `fowoco/ai` 책임입 "contractEndDate": "2026-12-31", "requestedFields": { "legal_name": "NGUYEN VAN AN", - "passport_number": "M12345678", - "phone": "010-1234-5678", - "email": "worker@example.com" + "stay_expiry_date": "2026-12-31" } } ], @@ -74,10 +136,12 @@ Prompt, Agent Pipeline, Provider retry와 모델 선택은 `fowoco/ai` 책임입 - `requestId`: Server 요청과 Runtime 응답을 같은 실행으로 연결합니다. - `attemptId`: 한 번의 `AiRuntimeClient.analyze` 호출과 정확히 하나로 대응합니다. +- `phase`: 발화문을 해석하는 `PLAN`과 Server 보유정보로 결과를 만드는 `ANALYZE`를 구분합니다. - `contractVersion`: 양쪽이 같은 JSON 계약을 사용하는지 확인합니다. - `requiredKnowledgeVersion`: Server와 Runtime이 같은 Workflow release를 사용하게 합니다. - `deadlineMs`: 이번 시도 전체에서 남은 실행 시간입니다. - `instruction`: HR이 입력한 원문입니다. 현재 데모에서는 가상 근로자 데이터만 사용합니다. +- `intentHint`: 화면 빠른 선택에서 온 선택값입니다. 없을 수 있으며 강제 Intent가 아닙니다. - `requestedFields`: Agent가 요구한 field의 원본값입니다. Server가 가진 값만 넣습니다. - `workflowConstraints`: Knowledge projection에서 가져온 Workflow와 slot allow-list입니다. @@ -89,12 +153,14 @@ Agent가 문서 작성에 요구한 값은 `***`, `OOO`로 바꾸지 않고 원 전송해서는 안 되며, 데모가 아닌 실제 개인정보를 사용하기 전에는 개인정보 처리 기준을 다시 확정해야 합니다. -## 응답 계약 +## ANALYZE 응답 계약 ```json { "requestId": "10000000-0000-0000-0000-000000000001", "outcome": "REVIEW_REQUIRED", + "contextRequirement": null, + "questions": [], "candidates": [ { "candidateRef": "candidate-1", @@ -126,9 +192,15 @@ Agent가 문서 작성에 요구한 값은 `***`, `OOO`로 바꾸지 않고 원 } ``` -`NEEDS_INFO`와 `REVIEW_REQUIRED`는 정상 분석 결과입니다. 이 값은 AiRun의 기술적 -`FAILED` 상태와 섞지 않습니다. Candidate는 Task도 승인도 아니며, #24에서 HR이 채택한 -후에만 Server Task command로 전달됩니다. +`CONTEXT_REQUIRED`, `NEEDS_INFO`, `REVIEW_REQUIRED`는 모두 정상 분석 결과이며 AiRun의 +기술적 `FAILED` 상태와 섞지 않습니다. + +- `CONTEXT_REQUIRED`: Server DB에서 조회할 canonical field key가 있습니다. +- `NEEDS_INFO`: DB로 채울 수 없어 HR에게 보여 줄 `questions`가 있습니다. +- `REVIEW_REQUIRED`: 검토 가능한 `candidates`가 있습니다. + +Candidate는 Task도 승인도 아니며, #24에서 HR이 채택한 후에만 Server Task command로 +전달됩니다. ## Server가 거부하는 응답 @@ -138,6 +210,8 @@ Agent가 문서 작성에 요구한 값은 `***`, `OOO`로 바꾸지 않고 원 - Workflow가 허용하지 않은 slot - 0 미만 또는 1 초과 confidence - 중복 candidate reference와 잘못된 outcome 구조 +- PLAN에 Worker DB context가 포함되거나 ANALYZE에 Worker context가 없는 요청 +- `CONTEXT_REQUIRED`인데 field key가 없거나, `NEEDS_INFO`인데 질문이 없는 응답 - API Key·JWT·Bearer Token·비밀번호·Worker Link token 같은 서비스 인증정보 거부 예외에는 발견한 원문을 넣지 않습니다. 앞으로 #24 AiAttempt에는 @@ -154,7 +228,7 @@ Agent가 문서 작성에 요구한 값은 `***`, `OOO`로 바꾸지 않고 원 WireMock 계약 테스트는 다음 동작을 검증합니다. 1. `Authorization: Bearer `, `X-Request-Id`, `traceparent` 전달 -2. 문서와 같은 camelCase 요청 JSON 사용 +2. 문서와 같은 camelCase 요청 JSON 및 `PLAN → CONTEXT_REQUIRED → ANALYZE` 구조 사용 3. 알 수 없는 JSON field와 제한보다 큰 응답 거부 4. connect timeout과 요청·응답 전체 deadline 5. circuit breaker와 동시 호출 수 bulkhead 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 index f3efd4d..fbe41fb 100644 --- a/src/main/java/com/fowoco/server/aiintegration/application/model/AiAnalysisOutcome.java +++ b/src/main/java/com/fowoco/server/aiintegration/application/model/AiAnalysisOutcome.java @@ -6,6 +6,7 @@ *

Low confidence and missing information are not transport failures.

*/ public enum AiAnalysisOutcome { + CONTEXT_REQUIRED, NEEDS_INFO, REVIEW_REQUIRED } diff --git a/src/main/java/com/fowoco/server/aiintegration/application/model/AiAnalysisPhase.java b/src/main/java/com/fowoco/server/aiintegration/application/model/AiAnalysisPhase.java new file mode 100644 index 0000000..074fda9 --- /dev/null +++ b/src/main/java/com/fowoco/server/aiintegration/application/model/AiAnalysisPhase.java @@ -0,0 +1,9 @@ +package com.fowoco.server.aiintegration.application.model; + +/** + * The current step of the Server-managed analysis conversation. + */ +public enum AiAnalysisPhase { + PLAN, + ANALYZE +} 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 index 37c1b2c..1c4fc4b 100644 --- a/src/main/java/com/fowoco/server/aiintegration/application/model/AiAnalysisRequest.java +++ b/src/main/java/com/fowoco/server/aiintegration/application/model/AiAnalysisRequest.java @@ -4,11 +4,12 @@ import java.util.UUID; /** - * Provider-neutral body for one Server-managed AI attempt. + * Provider-neutral body for one Server-managed PLAN or ANALYZE attempt. */ public record AiAnalysisRequest( UUID requestId, UUID attemptId, + AiAnalysisPhase phase, String contractVersion, String requiredKnowledgeVersion, long deadlineMs, @@ -18,6 +19,7 @@ public record AiAnalysisRequest( public AiAnalysisRequest { Objects.requireNonNull(requestId, "requestId must not be null"); Objects.requireNonNull(attemptId, "attemptId must not be null"); + Objects.requireNonNull(phase, "phase must not be null"); Objects.requireNonNull(contractVersion, "contractVersion must not be null"); Objects.requireNonNull(requiredKnowledgeVersion, "requiredKnowledgeVersion must not be null"); Objects.requireNonNull(analysisInput, "analysisInput 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 index d28b869..abaf18c 100644 --- a/src/main/java/com/fowoco/server/aiintegration/application/model/AiAnalysisResponse.java +++ b/src/main/java/com/fowoco/server/aiintegration/application/model/AiAnalysisResponse.java @@ -10,6 +10,8 @@ public record AiAnalysisResponse( UUID requestId, AiAnalysisOutcome outcome, + AiContextRequirement contextRequirement, + List questions, List candidates, List validationErrors, AiRuntimeVersions versions, @@ -20,9 +22,11 @@ public record AiAnalysisResponse( public AiAnalysisResponse { Objects.requireNonNull(requestId, "requestId must not be null"); Objects.requireNonNull(outcome, "outcome must not be null"); + Objects.requireNonNull(questions, "questions 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"); + questions = List.copyOf(questions); candidates = List.copyOf(candidates); validationErrors = List.copyOf(validationErrors); } diff --git a/src/main/java/com/fowoco/server/aiintegration/application/model/AiContextRequirement.java b/src/main/java/com/fowoco/server/aiintegration/application/model/AiContextRequirement.java new file mode 100644 index 0000000..c33c3de --- /dev/null +++ b/src/main/java/com/fowoco/server/aiintegration/application/model/AiContextRequirement.java @@ -0,0 +1,28 @@ +package com.fowoco.server.aiintegration.application.model; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Structured data request returned by the Runtime instead of querying the Server database directly. + */ +public record AiContextRequirement( + String detectedIntent, + BigDecimal confidence, + String targetDisplayName, + Map extractedSlots, + List requiredFieldKeys +) { + + public AiContextRequirement { + Objects.requireNonNull(detectedIntent, "detectedIntent must not be null"); + Objects.requireNonNull(confidence, "confidence must not be null"); + Objects.requireNonNull(targetDisplayName, "targetDisplayName must not be null"); + Objects.requireNonNull(extractedSlots, "extractedSlots must not be null"); + Objects.requireNonNull(requiredFieldKeys, "requiredFieldKeys must not be null"); + extractedSlots = Map.copyOf(extractedSlots); + requiredFieldKeys = List.copyOf(requiredFieldKeys); + } +} diff --git a/src/main/java/com/fowoco/server/aiintegration/application/model/AiQuestion.java b/src/main/java/com/fowoco/server/aiintegration/application/model/AiQuestion.java new file mode 100644 index 0000000..ed57739 --- /dev/null +++ b/src/main/java/com/fowoco/server/aiintegration/application/model/AiQuestion.java @@ -0,0 +1,17 @@ +package com.fowoco.server.aiintegration.application.model; + +import java.util.Objects; + +/** + * One missing field question that must be answered by an HR user. + */ +public record AiQuestion( + String slotKey, + String prompt +) { + + public AiQuestion { + Objects.requireNonNull(slotKey, "slotKey must not be null"); + Objects.requireNonNull(prompt, "prompt must not be null"); + } +} diff --git a/src/main/java/com/fowoco/server/aiintegration/application/model/AnalysisInput.java b/src/main/java/com/fowoco/server/aiintegration/application/model/AnalysisInput.java index f1847c2..dafe553 100644 --- a/src/main/java/com/fowoco/server/aiintegration/application/model/AnalysisInput.java +++ b/src/main/java/com/fowoco/server/aiintegration/application/model/AnalysisInput.java @@ -4,10 +4,12 @@ import java.util.Objects; /** - * Original HR instruction and Worker context sent to the AI Runtime for the current demo. + * HR instruction plus phase-specific context sent to the AI Runtime. + * PLAN keeps both collections empty; ANALYZE contains one Server-resolved Worker. */ public record AnalysisInput( String instruction, + String intentHint, List workers, List workflowConstraints ) { 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 index ff0a9d5..a9deb95 100644 --- a/src/main/java/com/fowoco/server/aiintegration/application/validation/AiRuntimeContractValidator.java +++ b/src/main/java/com/fowoco/server/aiintegration/application/validation/AiRuntimeContractValidator.java @@ -5,6 +5,8 @@ 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.AiContextRequirement; +import com.fowoco.server.aiintegration.application.model.AiQuestion; import com.fowoco.server.aiintegration.application.model.AiRuntimeVersions; import com.fowoco.server.aiintegration.application.model.WorkerContext; import com.fowoco.server.aiintegration.application.model.WorkflowConstraint; @@ -25,9 +27,11 @@ 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_WORKERS = 1; private static final int MAX_WORKFLOWS = 20; private static final int MAX_CANDIDATES = 50; + private static final int MAX_QUESTIONS = 50; + private static final int MAX_CONTEXT_FIELDS = 100; 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}"); @@ -48,8 +52,17 @@ public void validateRequest(AiAnalysisRequest request) { reject(AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT, "AI Runtime deadline is outside the allowed range."); } boundaryPolicy.validateText(request.analysisInput().instruction(), 10_000, true); - validateWorkers(request); - validateWorkflowConstraints(request); + if (request.analysisInput().intentHint() != null) { + boundaryPolicy.validateText(request.analysisInput().intentHint(), 128, true); + validateIdentifier( + request.analysisInput().intentHint(), + AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT + ); + } + switch (request.phase()) { + case PLAN -> validatePlanInput(request); + case ANALYZE -> validateAnalyzeInput(request); + } } public void validateResponse(AiAnalysisRequest request, AiAnalysisResponse response) { @@ -70,6 +83,9 @@ public void validateResponse(AiAnalysisRequest request, AiAnalysisResponse respo if (response.candidates().size() > MAX_CANDIDATES) { reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "AI Runtime returned too many candidates."); } + if (response.questions().size() > MAX_QUESTIONS) { + reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "AI Runtime returned too many questions."); + } Map> allowedSlotsByWorkflow = allowedSlotsByWorkflow(request); Map allowedWorkers = request.analysisInput().workers().stream() @@ -85,15 +101,28 @@ public void validateResponse(AiAnalysisRequest request, AiAnalysisResponse respo boundaryPolicy.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()) { + switch (response.outcome()) { + case CONTEXT_REQUIRED -> validateContextRequiredResponse(response); + case NEEDS_INFO -> validateNeedsInfoResponse(request, response); + case REVIEW_REQUIRED -> validateReviewRequiredResponse(response); + } + } + + private void validatePlanInput(AiAnalysisRequest request) { + if (!request.analysisInput().workers().isEmpty() + || !request.analysisInput().workflowConstraints().isEmpty()) { reject( - AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, - "REVIEW_REQUIRED response must include at least one candidate." + AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT, + "PLAN request must contain only the HR instruction and optional intent hint." ); } } + private void validateAnalyzeInput(AiAnalysisRequest request) { + validateWorkers(request); + validateWorkflowConstraints(request); + } + private void validateWorkers(AiAnalysisRequest request) { var workers = request.analysisInput().workers(); if (workers.isEmpty() || workers.size() > MAX_WORKERS) { @@ -126,6 +155,77 @@ private void validateWorkers(AiAnalysisRequest request) { } } + private void validateContextRequiredResponse(AiAnalysisResponse response) { + if (response.contextRequirement() == null + || !response.candidates().isEmpty() + || !response.questions().isEmpty()) { + reject( + AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, + "CONTEXT_REQUIRED response must include only one context requirement." + ); + } + validateContextRequirement(response.contextRequirement()); + } + + private void validateContextRequirement(AiContextRequirement requirement) { + validateIdentifier(requirement.detectedIntent(), AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT); + validateConfidence(requirement.confidence()); + boundaryPolicy.validateText(requirement.targetDisplayName(), 120, true); + if (requirement.extractedSlots().size() > MAX_CONTEXT_FIELDS + || requirement.requiredFieldKeys().isEmpty() + || requirement.requiredFieldKeys().size() > MAX_CONTEXT_FIELDS) { + reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "AI Runtime context field count is invalid."); + } + requirement.extractedSlots().forEach((key, value) -> { + boundaryPolicy.validateKey(key); + validateIdentifier(key, AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT); + boundaryPolicy.validateText(value, 4_000, true); + }); + Set requiredFieldKeys = new HashSet<>(); + requirement.requiredFieldKeys().forEach(key -> { + boundaryPolicy.validateKey(key); + validateIdentifier(key, AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT); + if (!requiredFieldKeys.add(key)) { + reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "AI Runtime required field key is invalid."); + } + }); + } + + private void validateNeedsInfoResponse(AiAnalysisRequest request, AiAnalysisResponse response) { + if (response.contextRequirement() != null + || !response.candidates().isEmpty() + || response.questions().isEmpty()) { + reject( + AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, + "NEEDS_INFO response must include HR questions only." + ); + } + Set allowedSlots = allAllowedSlots(request); + Set questionSlots = new HashSet<>(); + for (AiQuestion question : response.questions()) { + boundaryPolicy.validateKey(question.slotKey()); + validateIdentifier(question.slotKey(), AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT); + boundaryPolicy.validateText(question.prompt(), 500, true); + if (!questionSlots.add(question.slotKey())) { + reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "AI Runtime question slot is duplicated."); + } + if (!allowedSlots.isEmpty() && !allowedSlots.contains(question.slotKey())) { + reject(AiRuntimeFailureCode.UNEXPECTED_SLOT, "AI Runtime returned an unexpected question slot."); + } + } + } + + private void validateReviewRequiredResponse(AiAnalysisResponse response) { + if (response.contextRequirement() != null + || !response.questions().isEmpty() + || response.candidates().isEmpty()) { + reject( + AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, + "REVIEW_REQUIRED response must include candidates only." + ); + } + } + private void validateWorkflowConstraints(AiAnalysisRequest request) { var workflows = request.analysisInput().workflowConstraints(); if (workflows.isEmpty() || workflows.size() > MAX_WORKFLOWS) { @@ -179,10 +279,7 @@ private void validateCandidate( 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."); - } + validateConfidence(candidate.confidence()); candidate.extractedSlots().forEach((key, value) -> { validateAllowedSlot(key, allowedSlots); boundaryPolicy.validateText(value, 4_000, true); @@ -224,6 +321,21 @@ private Map> allowedSlotsByWorkflow(AiAnalysisRequest reques return Map.copyOf(allowed); } + private Set allAllowedSlots(AiAnalysisRequest request) { + Set allowedSlots = new HashSet<>(); + request.analysisInput().workflowConstraints() + .forEach(workflow -> allowedSlots.addAll(workflow.allowedSlotKeys())); + return Set.copyOf(allowedSlots); + } + + private void validateConfidence(BigDecimal confidence) { + if (confidence == null + || confidence.compareTo(BigDecimal.ZERO) < 0 + || confidence.compareTo(BigDecimal.ONE) > 0) { + reject(AiRuntimeFailureCode.INVALID_RESPONSE_CONTRACT, "AI Runtime confidence is invalid."); + } + } + private void validateVersion(String version, AiRuntimeFailureCode failureCode) { if (version == null || !VERSION.matcher(version).matches()) { reject(failureCode, "AI Runtime version is invalid."); diff --git a/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiRuntimeClient.java b/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiRuntimeClient.java index 7ccc578..3bf8737 100644 --- a/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiRuntimeClient.java +++ b/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiRuntimeClient.java @@ -263,6 +263,7 @@ private AiAnalysisRequest withRemainingDeadline(AiAnalysisRequest request, long return new AiAnalysisRequest( request.requestId(), request.attemptId(), + request.phase(), request.contractVersion(), request.requiredKnowledgeVersion(), remainingMillis, 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 index 89c72a1..aff34e0 100644 --- a/src/test/java/com/fowoco/server/aiintegration/application/model/AiRuntimeOutboundContractTest.java +++ b/src/test/java/com/fowoco/server/aiintegration/application/model/AiRuntimeOutboundContractTest.java @@ -1,6 +1,7 @@ package com.fowoco.server.aiintegration.application.model; import static com.fowoco.server.aiintegration.support.AiRuntimeContractFixture.validRequest; +import static com.fowoco.server.aiintegration.support.AiRuntimeContractFixture.validPlanRequest; import static org.assertj.core.api.Assertions.assertThat; import org.junit.jupiter.api.Test; @@ -11,6 +12,25 @@ class AiRuntimeOutboundContractTest { private final ObjectMapper objectMapper = new ObjectMapper(); + @Test + void planJsonContainsOnlyInstructionAndOptionalIntentHintAsBusinessInput() { + JsonNode json = objectMapper.valueToTree(validPlanRequest()); + JsonNode input = json.get("analysisInput"); + + assertThat(json.get("phase").textValue()).isEqualTo("PLAN"); + assertThat(input.get("instruction").textValue()).isEqualTo("응웬반안 체류연장 준비해줘"); + assertThat(input.get("intentHint").textValue()).isEqualTo("EXPIRY_RENEWAL"); + assertThat(input.get("workers").isEmpty()).isTrue(); + assertThat(input.get("workflowConstraints").isEmpty()).isTrue(); + assertThat(input.properties().stream().map(java.util.Map.Entry::getKey).toList()) + .containsExactlyInAnyOrder( + "instruction", + "intentHint", + "workers", + "workflowConstraints" + ); + } + @Test void outboundJsonContainsOriginalDemoDataWithoutServiceCredentials() throws Exception { JsonNode json = objectMapper.valueToTree(validRequest()); @@ -21,6 +41,7 @@ void outboundJsonContainsOriginalDemoDataWithoutServiceCredentials() throws Exce .containsExactlyInAnyOrder( "requestId", "attemptId", + "phase", "contractVersion", "requiredKnowledgeVersion", "deadlineMs", @@ -40,6 +61,7 @@ void outboundJsonContainsOriginalDemoDataWithoutServiceCredentials() throws Exce ); assertThat(input.get("instruction").textValue()) .contains("응웬반안", "010-1234-5678"); + assertThat(input.get("intentHint").textValue()).isEqualTo("EXPIRY_RENEWAL"); assertThat(worker.get("requestedFields").get("legal_name").textValue()) .isEqualTo("NGUYEN VAN AN"); assertThat(worker.get("requestedFields").get("passport_number").textValue()) 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 index 7fd8879..84ad545 100644 --- a/src/test/java/com/fowoco/server/aiintegration/application/validation/AiRuntimeContractValidatorTest.java +++ b/src/test/java/com/fowoco/server/aiintegration/application/validation/AiRuntimeContractValidatorTest.java @@ -1,13 +1,17 @@ package com.fowoco.server.aiintegration.application.validation; +import static com.fowoco.server.aiintegration.support.AiRuntimeContractFixture.ATTEMPT_ID; 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.contextRequiredResponse; +import static com.fowoco.server.aiintegration.support.AiRuntimeContractFixture.needsInfoResponse; 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.validPlanRequest; 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; @@ -17,9 +21,12 @@ 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.AiAnalysisPhase; +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.AnalysisInput; import com.fowoco.server.aiintegration.support.AiRuntimeContractFixture; import java.math.BigDecimal; import java.util.List; @@ -41,6 +48,39 @@ void acceptsValidRequestAndResponse() { .doesNotThrowAnyException(); } + @Test + void acceptsInstructionOnlyPlanAndStructuredContextRequirement() { + assertThatCode(() -> validator.validateResponse(validPlanRequest(), contextRequiredResponse())) + .doesNotThrowAnyException(); + } + + @Test + void acceptsQuestionsAsAValidBusinessOutcome() { + assertThatCode(() -> validator.validateResponse(validRequest(), needsInfoResponse())) + .doesNotThrowAnyException(); + } + + @Test + void keepsPlanAndAnalyzeInputsSeparated() { + AiAnalysisRequest planWithDatabaseContext = requestWithPhase( + AiAnalysisPhase.PLAN, + validRequest().analysisInput() + ); + AiAnalysisRequest analyzeWithoutDatabaseContext = requestWithPhase( + AiAnalysisPhase.ANALYZE, + validPlanRequest().analysisInput() + ); + + assertFailure( + () -> validator.validateRequest(planWithDatabaseContext), + AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT + ); + assertFailure( + () -> validator.validateRequest(analyzeWithoutDatabaseContext), + AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT + ); + } + @ParameterizedTest @MethodSource("credentialInstructions") void rejectsServiceCredentialsBeforeOutboundCall(String instruction) { @@ -82,6 +122,8 @@ void rejectsMismatchedRequestIdWithoutLeakingRawResponse() { AiAnalysisResponse response = new AiAnalysisResponse( UUID.randomUUID(), AiAnalysisOutcome.REVIEW_REQUIRED, + null, + List.of(), validResponse().candidates(), List.of(), validVersions(), @@ -190,6 +232,8 @@ private AiAnalysisResponse responseWithVersions(AiRuntimeVersions versions) { return new AiAnalysisResponse( REQUEST_ID, AiAnalysisOutcome.REVIEW_REQUIRED, + null, + List.of(), List.of(validCandidate()), List.of(), versions, @@ -198,6 +242,21 @@ private AiAnalysisResponse responseWithVersions(AiRuntimeVersions versions) { ); } + private AiAnalysisRequest requestWithPhase( + AiAnalysisPhase phase, + AnalysisInput input + ) { + return new AiAnalysisRequest( + REQUEST_ID, + ATTEMPT_ID, + phase, + CONTRACT_VERSION, + KNOWLEDGE_VERSION, + 10_000, + input + ); + } + private AiRuntimeVersions versions(String contractVersion, String knowledgeVersion) { AiRuntimeVersions valid = validVersions(); return new AiRuntimeVersions( 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 index 0a16ea8..18f1a8f 100644 --- a/src/test/java/com/fowoco/server/aiintegration/application/validation/ValidatingAiRuntimeClientTest.java +++ b/src/test/java/com/fowoco/server/aiintegration/application/validation/ValidatingAiRuntimeClientTest.java @@ -1,6 +1,8 @@ package com.fowoco.server.aiintegration.application.validation; +import static com.fowoco.server.aiintegration.support.AiRuntimeContractFixture.contextRequiredResponse; import static com.fowoco.server.aiintegration.support.AiRuntimeContractFixture.requestWithInstruction; +import static com.fowoco.server.aiintegration.support.AiRuntimeContractFixture.validPlanRequest; 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; @@ -25,6 +27,18 @@ void validatesBothSidesAndCapturesOneAttemptWithoutTransparentRetry() { assertThat(fake.receivedRequests()).containsExactly(validRequest()); } + @Test + void validatesPlanThenServerEnrichedAnalyzeAsTwoExplicitAttempts() { + FakeAiRuntimeClient fake = new FakeAiRuntimeClient(); + fake.enqueueResponse(contextRequiredResponse()); + fake.enqueueResponse(validResponse()); + ValidatingAiRuntimeClient client = new ValidatingAiRuntimeClient(fake, validator); + + assertThat(client.analyze(validPlanRequest())).isEqualTo(contextRequiredResponse()); + assertThat(client.analyze(validRequest())).isEqualTo(validResponse()); + assertThat(fake.receivedRequests()).containsExactly(validPlanRequest(), validRequest()); + } + @Test void rejectedInputNeverReachesTransport() { FakeAiRuntimeClient fake = new FakeAiRuntimeClient(); diff --git a/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiRuntimeClientWireMockTest.java b/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiRuntimeClientWireMockTest.java index 6785bc6..986f2e5 100644 --- a/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiRuntimeClientWireMockTest.java +++ b/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiRuntimeClientWireMockTest.java @@ -1,6 +1,8 @@ package com.fowoco.server.aiintegration.infrastructure.http; import static com.fowoco.server.aiintegration.support.AiRuntimeContractFixture.REQUEST_ID; +import static com.fowoco.server.aiintegration.support.AiRuntimeContractFixture.contextRequiredResponse; +import static com.fowoco.server.aiintegration.support.AiRuntimeContractFixture.validPlanRequest; import static com.fowoco.server.aiintegration.support.AiRuntimeContractFixture.validRequest; import static com.fowoco.server.aiintegration.support.AiRuntimeContractFixture.validResponse; import static com.github.tomakehurst.wiremock.client.WireMock.absent; @@ -70,6 +72,10 @@ void sendsCanonicalHeadersAndCamelCaseBodyThenParsesResponse() throws Exception "$.requestId", equalTo(REQUEST_ID.toString()) )) + .withRequestBody(com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath( + "$.phase", + equalTo("ANALYZE") + )) .withRequestBody(com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath( "$.analysisInput.workers[0].stayExpiryDate" )) @@ -90,6 +96,34 @@ void sendsCanonicalHeadersAndCamelCaseBodyThenParsesResponse() throws Exception wireMock.verify(exactly(1), postRequestedFor(urlEqualTo(PATH))); } + @Test + void sendsInstructionOnlyPlanAndParsesContextRequirement() throws Exception { + wireMock.stubFor(post(urlEqualTo(PATH)) + .withRequestBody(com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath( + "$.phase", + equalTo("PLAN") + )) + .withRequestBody(com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath( + "$.analysisInput.instruction", + equalTo("응웬반안 체류연장 준비해줘") + )) + .withRequestBody(com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath( + "$.analysisInput.intentHint", + equalTo("EXPIRY_RENEWAL") + )) + .withRequestBody(com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath( + "$.analysisInput.workers[0]", + absent() + )) + .willReturn(jsonResponse(objectMapper.writeValueAsString(contextRequiredResponse())))); + + AiAnalysisResponse response = client(1_048_576, 8, 5, Duration.ofSeconds(30)) + .analyze(validPlanRequest(), new AiRuntimeCallContext(TRACEPARENT)); + + assertThat(response).isEqualTo(contextRequiredResponse()); + wireMock.verify(exactly(1), postRequestedFor(urlEqualTo(PATH))); + } + @Test void doesNotRetryAndOpensCircuitAfterConsecutiveRuntimeFailures() { wireMock.stubFor(post(urlEqualTo(PATH)).willReturn(aResponse().withStatus(503))); diff --git a/src/test/java/com/fowoco/server/aiintegration/support/AiRuntimeContractFixture.java b/src/test/java/com/fowoco/server/aiintegration/support/AiRuntimeContractFixture.java index ee274b2..8510763 100644 --- a/src/test/java/com/fowoco/server/aiintegration/support/AiRuntimeContractFixture.java +++ b/src/test/java/com/fowoco/server/aiintegration/support/AiRuntimeContractFixture.java @@ -1,9 +1,12 @@ package com.fowoco.server.aiintegration.support; import com.fowoco.server.aiintegration.application.model.AiAnalysisOutcome; +import com.fowoco.server.aiintegration.application.model.AiAnalysisPhase; 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.AiContextRequirement; +import com.fowoco.server.aiintegration.application.model.AiQuestion; import com.fowoco.server.aiintegration.application.model.AiRuntimeVersions; import com.fowoco.server.aiintegration.application.model.AnalysisInput; import com.fowoco.server.aiintegration.application.model.WorkerContext; @@ -28,6 +31,26 @@ private AiRuntimeContractFixture() { } public static AiAnalysisRequest validRequest() { + return validAnalyzeRequest(); + } + + public static AiAnalysisRequest validPlanRequest() { + return planRequestWithInstruction("응웬반안 체류연장 준비해줘", "EXPIRY_RENEWAL"); + } + + public static AiAnalysisRequest planRequestWithInstruction(String instruction, String intentHint) { + return new AiAnalysisRequest( + REQUEST_ID, + ATTEMPT_ID, + AiAnalysisPhase.PLAN, + CONTRACT_VERSION, + KNOWLEDGE_VERSION, + 10_000, + new AnalysisInput(instruction, intentHint, List.of(), List.of()) + ); + } + + public static AiAnalysisRequest validAnalyzeRequest() { return requestWithInstruction( "가상 근로자 응웬반안(010-1234-5678)의 체류연장 준비" ); @@ -37,11 +60,13 @@ public static AiAnalysisRequest requestWithInstruction(String instruction) { return new AiAnalysisRequest( REQUEST_ID, ATTEMPT_ID, + AiAnalysisPhase.ANALYZE, CONTRACT_VERSION, KNOWLEDGE_VERSION, 10_000, new AnalysisInput( instruction, + "EXPIRY_RENEWAL", List.of(new WorkerContext( WORKER_REF, "응웬반안", @@ -70,10 +95,46 @@ public static AiAnalysisResponse validResponse() { return responseWithCandidate(validCandidate()); } + public static AiAnalysisResponse contextRequiredResponse() { + return new AiAnalysisResponse( + REQUEST_ID, + AiAnalysisOutcome.CONTEXT_REQUIRED, + new AiContextRequirement( + WORKFLOW_ID, + new BigDecimal("0.94"), + "응웬반안", + Map.of(), + List.of("legal_name", "stay_expiry_date") + ), + List.of(), + List.of(), + List.of(), + validVersions(), + 1, + 120 + ); + } + + public static AiAnalysisResponse needsInfoResponse() { + return new AiAnalysisResponse( + REQUEST_ID, + AiAnalysisOutcome.NEEDS_INFO, + null, + List.of(new AiQuestion("monthly_wage", "변경할 월 임금을 입력해 주세요.")), + List.of(), + List.of(), + validVersions(), + 1, + 180 + ); + } + public static AiAnalysisResponse responseWithCandidate(AiCandidate candidate) { return new AiAnalysisResponse( REQUEST_ID, AiAnalysisOutcome.REVIEW_REQUIRED, + null, + List.of(), List.of(candidate), List.of(), validVersions(), From 35ee1b704cd11260990bebdefc9b41048ea65fcb Mon Sep 17 00:00:00 2001 From: hywznn Date: Mon, 3 Aug 2026 15:38:32 +0900 Subject: [PATCH 07/10] =?UTF-8?q?fix(ai):=20ANALYZE=20=EB=AC=B8=EB=A7=A5?= =?UTF-8?q?=20=EB=B3=B4=EC=A1=B4=20=ED=95=84=EB=93=9C=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ai-runtime-contract.md | 9 +++++ .../application/model/AnalysisInput.java | 10 ++++- .../AiRuntimeContractValidator.java | 40 ++++++++++++++++++- .../model/AiRuntimeOutboundContractTest.java | 7 ++++ .../RemoteAiRuntimeClientWireMockTest.java | 4 +- .../support/AiRuntimeContractFixture.java | 9 ++++- 6 files changed, 74 insertions(+), 5 deletions(-) diff --git a/docs/ai-runtime-contract.md b/docs/ai-runtime-contract.md index e6a76d9..63c8979 100644 --- a/docs/ai-runtime-contract.md +++ b/docs/ai-runtime-contract.md @@ -46,6 +46,8 @@ Prompt, Agent Pipeline, Provider retry와 모델 선택은 `fowoco/ai` 책임입 "analysisInput": { "instruction": "응웬반안 체류연장 준비해줘", "intentHint": "EXPIRY_RENEWAL", + "extractedSlots": {}, + "requestedFieldKeys": [], "workers": [], "workflowConstraints": [] } @@ -104,6 +106,11 @@ ANALYZE를 호출합니다. MVP에서는 한 요청에 Worker 한 명만 허용 "analysisInput": { "instruction": "응웬반안 체류연장 준비해줘", "intentHint": "EXPIRY_RENEWAL", + "extractedSlots": {}, + "requestedFieldKeys": [ + "legal_name", + "stay_expiry_date" + ], "workers": [ { "workerRef": "30000000-0000-0000-0000-000000000001", @@ -142,6 +149,8 @@ ANALYZE를 호출합니다. MVP에서는 한 요청에 Worker 한 명만 허용 - `deadlineMs`: 이번 시도 전체에서 남은 실행 시간입니다. - `instruction`: HR이 입력한 원문입니다. 현재 데모에서는 가상 근로자 데이터만 사용합니다. - `intentHint`: 화면 빠른 선택에서 온 선택값입니다. 없을 수 있으며 강제 Intent가 아닙니다. +- `extractedSlots`: PLAN에서 Agent가 발화문으로부터 추출했던 값을 ANALYZE에도 보존합니다. +- `requestedFieldKeys`: Agent가 PLAN에서 요청했던 전체 key입니다. DB에 값이 없어도 목록에는 남습니다. - `requestedFields`: Agent가 요구한 field의 원본값입니다. Server가 가진 값만 넣습니다. - `workflowConstraints`: Knowledge projection에서 가져온 Workflow와 slot allow-list입니다. diff --git a/src/main/java/com/fowoco/server/aiintegration/application/model/AnalysisInput.java b/src/main/java/com/fowoco/server/aiintegration/application/model/AnalysisInput.java index dafe553..6f11bdc 100644 --- a/src/main/java/com/fowoco/server/aiintegration/application/model/AnalysisInput.java +++ b/src/main/java/com/fowoco/server/aiintegration/application/model/AnalysisInput.java @@ -1,23 +1,31 @@ package com.fowoco.server.aiintegration.application.model; import java.util.List; +import java.util.Map; import java.util.Objects; /** * HR instruction plus phase-specific context sent to the AI Runtime. - * PLAN keeps both collections empty; ANALYZE contains one Server-resolved Worker. + * PLAN keeps context collections empty; ANALYZE preserves extracted and requested fields and + * contains one Server-resolved Worker. */ public record AnalysisInput( String instruction, String intentHint, + Map extractedSlots, + List requestedFieldKeys, List workers, List workflowConstraints ) { public AnalysisInput { Objects.requireNonNull(instruction, "instruction must not be null"); + Objects.requireNonNull(extractedSlots, "extractedSlots must not be null"); + Objects.requireNonNull(requestedFieldKeys, "requestedFieldKeys must not be null"); Objects.requireNonNull(workers, "workers must not be null"); Objects.requireNonNull(workflowConstraints, "workflowConstraints must not be null"); + extractedSlots = Map.copyOf(extractedSlots); + requestedFieldKeys = List.copyOf(requestedFieldKeys); workers = List.copyOf(workers); workflowConstraints = List.copyOf(workflowConstraints); } 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 index a9deb95..c52065d 100644 --- a/src/main/java/com/fowoco/server/aiintegration/application/validation/AiRuntimeContractValidator.java +++ b/src/main/java/com/fowoco/server/aiintegration/application/validation/AiRuntimeContractValidator.java @@ -59,6 +59,7 @@ public void validateRequest(AiAnalysisRequest request) { AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT ); } + validateAnalysisContext(request); switch (request.phase()) { case PLAN -> validatePlanInput(request); case ANALYZE -> validateAnalyzeInput(request); @@ -109,7 +110,9 @@ public void validateResponse(AiAnalysisRequest request, AiAnalysisResponse respo } private void validatePlanInput(AiAnalysisRequest request) { - if (!request.analysisInput().workers().isEmpty() + if (!request.analysisInput().extractedSlots().isEmpty() + || !request.analysisInput().requestedFieldKeys().isEmpty() + || !request.analysisInput().workers().isEmpty() || !request.analysisInput().workflowConstraints().isEmpty()) { reject( AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT, @@ -119,8 +122,43 @@ private void validatePlanInput(AiAnalysisRequest request) { } private void validateAnalyzeInput(AiAnalysisRequest request) { + if (request.analysisInput().requestedFieldKeys().isEmpty()) { + reject( + AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT, + "ANALYZE request must identify the context fields requested by the Runtime." + ); + } validateWorkers(request); validateWorkflowConstraints(request); + Set requestedFieldKeys = Set.copyOf(request.analysisInput().requestedFieldKeys()); + request.analysisInput().workers().forEach(worker -> { + if (!requestedFieldKeys.containsAll(worker.requestedFields().keySet())) { + reject( + AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT, + "ANALYZE request contains a field that the Runtime did not request." + ); + } + }); + } + + private void validateAnalysisContext(AiAnalysisRequest request) { + if (request.analysisInput().extractedSlots().size() > MAX_CONTEXT_FIELDS + || request.analysisInput().requestedFieldKeys().size() > MAX_CONTEXT_FIELDS) { + reject(AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT, "AI Runtime context field count is invalid."); + } + request.analysisInput().extractedSlots().forEach((key, value) -> { + boundaryPolicy.validateKey(key); + validateIdentifier(key, AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT); + boundaryPolicy.validateText(value, 4_000, true); + }); + Set requestedFieldKeys = new HashSet<>(); + request.analysisInput().requestedFieldKeys().forEach(key -> { + boundaryPolicy.validateKey(key); + validateIdentifier(key, AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT); + if (!requestedFieldKeys.add(key)) { + reject(AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT, "AI Runtime field key is duplicated."); + } + }); } private void validateWorkers(AiAnalysisRequest request) { 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 index aff34e0..78f10c2 100644 --- a/src/test/java/com/fowoco/server/aiintegration/application/model/AiRuntimeOutboundContractTest.java +++ b/src/test/java/com/fowoco/server/aiintegration/application/model/AiRuntimeOutboundContractTest.java @@ -20,12 +20,16 @@ void planJsonContainsOnlyInstructionAndOptionalIntentHintAsBusinessInput() { assertThat(json.get("phase").textValue()).isEqualTo("PLAN"); assertThat(input.get("instruction").textValue()).isEqualTo("응웬반안 체류연장 준비해줘"); assertThat(input.get("intentHint").textValue()).isEqualTo("EXPIRY_RENEWAL"); + assertThat(input.get("extractedSlots").isEmpty()).isTrue(); + assertThat(input.get("requestedFieldKeys").isEmpty()).isTrue(); assertThat(input.get("workers").isEmpty()).isTrue(); assertThat(input.get("workflowConstraints").isEmpty()).isTrue(); assertThat(input.properties().stream().map(java.util.Map.Entry::getKey).toList()) .containsExactlyInAnyOrder( "instruction", "intentHint", + "extractedSlots", + "requestedFieldKeys", "workers", "workflowConstraints" ); @@ -62,6 +66,9 @@ void outboundJsonContainsOriginalDemoDataWithoutServiceCredentials() throws Exce assertThat(input.get("instruction").textValue()) .contains("응웬반안", "010-1234-5678"); assertThat(input.get("intentHint").textValue()).isEqualTo("EXPIRY_RENEWAL"); + assertThat(input.get("extractedSlots").get("document_type").textValue()) + .isEqualTo("STAY_EXTENSION"); + assertThat(input.get("requestedFieldKeys")).hasSize(4); assertThat(worker.get("requestedFields").get("legal_name").textValue()) .isEqualTo("NGUYEN VAN AN"); assertThat(worker.get("requestedFields").get("passport_number").textValue()) diff --git a/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiRuntimeClientWireMockTest.java b/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiRuntimeClientWireMockTest.java index 986f2e5..c7ef0e1 100644 --- a/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiRuntimeClientWireMockTest.java +++ b/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiRuntimeClientWireMockTest.java @@ -152,13 +152,13 @@ void rejectsUnknownResponseFieldWithStableParsingFailure() throws Exception { @Test void enforcesOverallDeadline() { wireMock.stubFor(post(urlEqualTo(PATH)) - .willReturn(jsonResponse("{}").withFixedDelay(500))); + .willReturn(jsonResponse("{}").withFixedDelay(2_000))); AiRuntimeClient client = client( 1_048_576, 8, 5, - Duration.ofMillis(200) + Duration.ofSeconds(1) ); assertFailureCode(client, AiRuntimeFailureCode.DEADLINE_EXCEEDED); diff --git a/src/test/java/com/fowoco/server/aiintegration/support/AiRuntimeContractFixture.java b/src/test/java/com/fowoco/server/aiintegration/support/AiRuntimeContractFixture.java index 8510763..c702b90 100644 --- a/src/test/java/com/fowoco/server/aiintegration/support/AiRuntimeContractFixture.java +++ b/src/test/java/com/fowoco/server/aiintegration/support/AiRuntimeContractFixture.java @@ -46,7 +46,7 @@ public static AiAnalysisRequest planRequestWithInstruction(String instruction, S CONTRACT_VERSION, KNOWLEDGE_VERSION, 10_000, - new AnalysisInput(instruction, intentHint, List.of(), List.of()) + new AnalysisInput(instruction, intentHint, Map.of(), List.of(), List.of(), List.of()) ); } @@ -67,6 +67,13 @@ public static AiAnalysisRequest requestWithInstruction(String instruction) { new AnalysisInput( instruction, "EXPIRY_RENEWAL", + Map.of("document_type", "STAY_EXTENSION"), + List.of( + "legal_name", + "passport_number", + "phone", + "email" + ), List.of(new WorkerContext( WORKER_REF, "응웬반안", From adc486cfa7f4ad3f34c1846b4e8586b161c85549 Mon Sep 17 00:00:00 2001 From: hywznn Date: Mon, 3 Aug 2026 15:54:32 +0900 Subject: [PATCH 08/10] =?UTF-8?q?fix(ai):=20Intent=20=ED=83=9C=EA=B7=B8?= =?UTF-8?q?=EB=A5=BC=20=EB=B0=9C=ED=99=94=EB=AC=B8=20=EB=8B=A8=EC=9D=BC=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=EC=9C=BC=EB=A1=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ai-runtime-contract.md | 16 ++++++++-------- .../application/model/AnalysisInput.java | 1 - .../validation/AiRuntimeContractValidator.java | 9 +-------- .../model/AiRuntimeOutboundContractTest.java | 11 ++++++----- .../http/RemoteAiRuntimeClientWireMockTest.java | 4 ++-- .../support/AiRuntimeContractFixture.java | 9 ++++----- 6 files changed, 21 insertions(+), 29 deletions(-) diff --git a/docs/ai-runtime-contract.md b/docs/ai-runtime-contract.md index 63c8979..77316bc 100644 --- a/docs/ai-runtime-contract.md +++ b/docs/ai-runtime-contract.md @@ -32,8 +32,10 @@ Prompt, Agent Pipeline, Provider retry와 모델 선택은 `fowoco/ai` 책임입 ## PLAN 요청 계약 첫 호출은 HR 발화문을 이해하고 Server에 필요한 DB field를 요청하는 단계입니다. 화면의 -빠른 선택 태그는 `intentHint`에 넣지만 참고 정보일 뿐이며, 최종 분류 결과는 Runtime이 -`detectedIntent`로 반환합니다. 이 단계에는 Worker UUID나 DB 조회값을 넣지 않습니다. +빠른 선택 태그는 별도 JSON 필드로 보내지 않고 `발화문, INTENT_TAG` 형식으로 +`instruction` 끝에 붙입니다. Runtime이 받는 업무 입력은 이 문자열 하나이며, 최종 분류 +결과는 Runtime이 `detectedIntent`로 반환합니다. 이 단계에는 Worker UUID나 DB 조회값을 +넣지 않습니다. ```json { @@ -44,8 +46,7 @@ Prompt, Agent Pipeline, Provider retry와 모델 선택은 `fowoco/ai` 책임입 "requiredKnowledgeVersion": "0.2.0", "deadlineMs": 10000, "analysisInput": { - "instruction": "응웬반안 체류연장 준비해줘", - "intentHint": "EXPIRY_RENEWAL", + "instruction": "응웬반안 체류연장 준비해줘, EXPIRY_RENEWAL", "extractedSlots": {}, "requestedFieldKeys": [], "workers": [], @@ -104,8 +105,7 @@ ANALYZE를 호출합니다. MVP에서는 한 요청에 Worker 한 명만 허용 "requiredKnowledgeVersion": "0.2.0", "deadlineMs": 10000, "analysisInput": { - "instruction": "응웬반안 체류연장 준비해줘", - "intentHint": "EXPIRY_RENEWAL", + "instruction": "응웬반안 체류연장 준비해줘, EXPIRY_RENEWAL", "extractedSlots": {}, "requestedFieldKeys": [ "legal_name", @@ -147,8 +147,8 @@ ANALYZE를 호출합니다. MVP에서는 한 요청에 Worker 한 명만 허용 - `contractVersion`: 양쪽이 같은 JSON 계약을 사용하는지 확인합니다. - `requiredKnowledgeVersion`: Server와 Runtime이 같은 Workflow release를 사용하게 합니다. - `deadlineMs`: 이번 시도 전체에서 남은 실행 시간입니다. -- `instruction`: HR이 입력한 원문입니다. 현재 데모에서는 가상 근로자 데이터만 사용합니다. -- `intentHint`: 화면 빠른 선택에서 온 선택값입니다. 없을 수 있으며 강제 Intent가 아닙니다. +- `instruction`: HR 발화문에 선택한 태그가 있으면 `발화문, INTENT_TAG` 형식으로 붙인 + 단일 문자열입니다. 현재 데모에서는 가상 근로자 데이터만 사용합니다. - `extractedSlots`: PLAN에서 Agent가 발화문으로부터 추출했던 값을 ANALYZE에도 보존합니다. - `requestedFieldKeys`: Agent가 PLAN에서 요청했던 전체 key입니다. DB에 값이 없어도 목록에는 남습니다. - `requestedFields`: Agent가 요구한 field의 원본값입니다. Server가 가진 값만 넣습니다. diff --git a/src/main/java/com/fowoco/server/aiintegration/application/model/AnalysisInput.java b/src/main/java/com/fowoco/server/aiintegration/application/model/AnalysisInput.java index 6f11bdc..b022bdf 100644 --- a/src/main/java/com/fowoco/server/aiintegration/application/model/AnalysisInput.java +++ b/src/main/java/com/fowoco/server/aiintegration/application/model/AnalysisInput.java @@ -11,7 +11,6 @@ */ public record AnalysisInput( String instruction, - String intentHint, Map extractedSlots, List requestedFieldKeys, List workers, 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 index c52065d..d45a488 100644 --- a/src/main/java/com/fowoco/server/aiintegration/application/validation/AiRuntimeContractValidator.java +++ b/src/main/java/com/fowoco/server/aiintegration/application/validation/AiRuntimeContractValidator.java @@ -52,13 +52,6 @@ public void validateRequest(AiAnalysisRequest request) { reject(AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT, "AI Runtime deadline is outside the allowed range."); } boundaryPolicy.validateText(request.analysisInput().instruction(), 10_000, true); - if (request.analysisInput().intentHint() != null) { - boundaryPolicy.validateText(request.analysisInput().intentHint(), 128, true); - validateIdentifier( - request.analysisInput().intentHint(), - AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT - ); - } validateAnalysisContext(request); switch (request.phase()) { case PLAN -> validatePlanInput(request); @@ -116,7 +109,7 @@ private void validatePlanInput(AiAnalysisRequest request) { || !request.analysisInput().workflowConstraints().isEmpty()) { reject( AiRuntimeFailureCode.INVALID_REQUEST_CONTRACT, - "PLAN request must contain only the HR instruction and optional intent hint." + "PLAN request must contain only the HR instruction." ); } } 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 index 78f10c2..39c2f38 100644 --- a/src/test/java/com/fowoco/server/aiintegration/application/model/AiRuntimeOutboundContractTest.java +++ b/src/test/java/com/fowoco/server/aiintegration/application/model/AiRuntimeOutboundContractTest.java @@ -13,13 +13,14 @@ class AiRuntimeOutboundContractTest { private final ObjectMapper objectMapper = new ObjectMapper(); @Test - void planJsonContainsOnlyInstructionAndOptionalIntentHintAsBusinessInput() { + void planJsonContainsCombinedInstructionAsTheOnlyBusinessInput() { JsonNode json = objectMapper.valueToTree(validPlanRequest()); JsonNode input = json.get("analysisInput"); assertThat(json.get("phase").textValue()).isEqualTo("PLAN"); - assertThat(input.get("instruction").textValue()).isEqualTo("응웬반안 체류연장 준비해줘"); - assertThat(input.get("intentHint").textValue()).isEqualTo("EXPIRY_RENEWAL"); + assertThat(input.get("instruction").textValue()) + .isEqualTo("응웬반안 체류연장 준비해줘, EXPIRY_RENEWAL"); + assertThat(input.has("intentHint")).isFalse(); assertThat(input.get("extractedSlots").isEmpty()).isTrue(); assertThat(input.get("requestedFieldKeys").isEmpty()).isTrue(); assertThat(input.get("workers").isEmpty()).isTrue(); @@ -27,7 +28,6 @@ void planJsonContainsOnlyInstructionAndOptionalIntentHintAsBusinessInput() { assertThat(input.properties().stream().map(java.util.Map.Entry::getKey).toList()) .containsExactlyInAnyOrder( "instruction", - "intentHint", "extractedSlots", "requestedFieldKeys", "workers", @@ -65,7 +65,8 @@ void outboundJsonContainsOriginalDemoDataWithoutServiceCredentials() throws Exce ); assertThat(input.get("instruction").textValue()) .contains("응웬반안", "010-1234-5678"); - assertThat(input.get("intentHint").textValue()).isEqualTo("EXPIRY_RENEWAL"); + assertThat(input.get("instruction").textValue()).endsWith(", EXPIRY_RENEWAL"); + assertThat(input.has("intentHint")).isFalse(); assertThat(input.get("extractedSlots").get("document_type").textValue()) .isEqualTo("STAY_EXTENSION"); assertThat(input.get("requestedFieldKeys")).hasSize(4); diff --git a/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiRuntimeClientWireMockTest.java b/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiRuntimeClientWireMockTest.java index c7ef0e1..e525751 100644 --- a/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiRuntimeClientWireMockTest.java +++ b/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiRuntimeClientWireMockTest.java @@ -105,11 +105,11 @@ void sendsInstructionOnlyPlanAndParsesContextRequirement() throws Exception { )) .withRequestBody(com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath( "$.analysisInput.instruction", - equalTo("응웬반안 체류연장 준비해줘") + equalTo("응웬반안 체류연장 준비해줘, EXPIRY_RENEWAL") )) .withRequestBody(com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath( "$.analysisInput.intentHint", - equalTo("EXPIRY_RENEWAL") + absent() )) .withRequestBody(com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath( "$.analysisInput.workers[0]", diff --git a/src/test/java/com/fowoco/server/aiintegration/support/AiRuntimeContractFixture.java b/src/test/java/com/fowoco/server/aiintegration/support/AiRuntimeContractFixture.java index c702b90..828cd59 100644 --- a/src/test/java/com/fowoco/server/aiintegration/support/AiRuntimeContractFixture.java +++ b/src/test/java/com/fowoco/server/aiintegration/support/AiRuntimeContractFixture.java @@ -35,10 +35,10 @@ public static AiAnalysisRequest validRequest() { } public static AiAnalysisRequest validPlanRequest() { - return planRequestWithInstruction("응웬반안 체류연장 준비해줘", "EXPIRY_RENEWAL"); + return planRequestWithInstruction("응웬반안 체류연장 준비해줘, EXPIRY_RENEWAL"); } - public static AiAnalysisRequest planRequestWithInstruction(String instruction, String intentHint) { + public static AiAnalysisRequest planRequestWithInstruction(String instruction) { return new AiAnalysisRequest( REQUEST_ID, ATTEMPT_ID, @@ -46,13 +46,13 @@ public static AiAnalysisRequest planRequestWithInstruction(String instruction, S CONTRACT_VERSION, KNOWLEDGE_VERSION, 10_000, - new AnalysisInput(instruction, intentHint, Map.of(), List.of(), List.of(), List.of()) + new AnalysisInput(instruction, Map.of(), List.of(), List.of(), List.of()) ); } public static AiAnalysisRequest validAnalyzeRequest() { return requestWithInstruction( - "가상 근로자 응웬반안(010-1234-5678)의 체류연장 준비" + "가상 근로자 응웬반안(010-1234-5678)의 체류연장 준비, EXPIRY_RENEWAL" ); } @@ -66,7 +66,6 @@ public static AiAnalysisRequest requestWithInstruction(String instruction) { 10_000, new AnalysisInput( instruction, - "EXPIRY_RENEWAL", Map.of("document_type", "STAY_EXTENSION"), List.of( "legal_name", From 0383f7907b484aa271f616c85b371e277d652fb0 Mon Sep 17 00:00:00 2001 From: hywznn Date: Mon, 3 Aug 2026 16:22:53 +0900 Subject: [PATCH 09/10] =?UTF-8?q?refactor(ai):=20PLAN=20=EC=9A=94=EC=B2=AD?= =?UTF-8?q?=EC=9D=98=20=EB=B9=88=20Context=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EC=83=9D=EB=9E=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ai-runtime-contract.md | 10 +++++----- .../application/model/AnalysisInput.java | 2 ++ .../model/AiRuntimeOutboundContractTest.java | 16 +++++----------- .../http/RemoteAiRuntimeClientWireMockTest.java | 14 +++++++++++++- 4 files changed, 25 insertions(+), 17 deletions(-) diff --git a/docs/ai-runtime-contract.md b/docs/ai-runtime-contract.md index 77316bc..5c602b4 100644 --- a/docs/ai-runtime-contract.md +++ b/docs/ai-runtime-contract.md @@ -46,15 +46,15 @@ Prompt, Agent Pipeline, Provider retry와 모델 선택은 `fowoco/ai` 책임입 "requiredKnowledgeVersion": "0.2.0", "deadlineMs": 10000, "analysisInput": { - "instruction": "응웬반안 체류연장 준비해줘, EXPIRY_RENEWAL", - "extractedSlots": {}, - "requestedFieldKeys": [], - "workers": [], - "workflowConstraints": [] + "instruction": "응웬반안 체류연장 준비해줘, EXPIRY_RENEWAL" } } ``` +PLAN에서 아직 값이 없는 `extractedSlots`, `requestedFieldKeys`, `workers`, +`workflowConstraints`는 JSON에 보내지 않습니다. Server 내부에서는 빈 collection으로 +유지하며 ANALYZE에서 실제 값이 생겼을 때만 직렬화합니다. + Runtime이 DB 정보를 더 필요로 하면 성공 응답으로 `CONTEXT_REQUIRED`를 반환합니다. Agent는 SQL을 만들거나 DB를 직접 조회하지 않고, canonical field key만 요청합니다. diff --git a/src/main/java/com/fowoco/server/aiintegration/application/model/AnalysisInput.java b/src/main/java/com/fowoco/server/aiintegration/application/model/AnalysisInput.java index b022bdf..59b8d57 100644 --- a/src/main/java/com/fowoco/server/aiintegration/application/model/AnalysisInput.java +++ b/src/main/java/com/fowoco/server/aiintegration/application/model/AnalysisInput.java @@ -1,5 +1,6 @@ package com.fowoco.server.aiintegration.application.model; +import com.fasterxml.jackson.annotation.JsonInclude; import java.util.List; import java.util.Map; import java.util.Objects; @@ -9,6 +10,7 @@ * PLAN keeps context collections empty; ANALYZE preserves extracted and requested fields and * contains one Server-resolved Worker. */ +@JsonInclude(JsonInclude.Include.NON_EMPTY) public record AnalysisInput( String instruction, Map extractedSlots, 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 index 39c2f38..e4dae92 100644 --- a/src/test/java/com/fowoco/server/aiintegration/application/model/AiRuntimeOutboundContractTest.java +++ b/src/test/java/com/fowoco/server/aiintegration/application/model/AiRuntimeOutboundContractTest.java @@ -21,18 +21,12 @@ void planJsonContainsCombinedInstructionAsTheOnlyBusinessInput() { assertThat(input.get("instruction").textValue()) .isEqualTo("응웬반안 체류연장 준비해줘, EXPIRY_RENEWAL"); assertThat(input.has("intentHint")).isFalse(); - assertThat(input.get("extractedSlots").isEmpty()).isTrue(); - assertThat(input.get("requestedFieldKeys").isEmpty()).isTrue(); - assertThat(input.get("workers").isEmpty()).isTrue(); - assertThat(input.get("workflowConstraints").isEmpty()).isTrue(); + assertThat(input.has("extractedSlots")).isFalse(); + assertThat(input.has("requestedFieldKeys")).isFalse(); + assertThat(input.has("workers")).isFalse(); + assertThat(input.has("workflowConstraints")).isFalse(); assertThat(input.properties().stream().map(java.util.Map.Entry::getKey).toList()) - .containsExactlyInAnyOrder( - "instruction", - "extractedSlots", - "requestedFieldKeys", - "workers", - "workflowConstraints" - ); + .containsExactly("instruction"); } @Test diff --git a/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiRuntimeClientWireMockTest.java b/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiRuntimeClientWireMockTest.java index e525751..21f989a 100644 --- a/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiRuntimeClientWireMockTest.java +++ b/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiRuntimeClientWireMockTest.java @@ -112,7 +112,19 @@ void sendsInstructionOnlyPlanAndParsesContextRequirement() throws Exception { absent() )) .withRequestBody(com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath( - "$.analysisInput.workers[0]", + "$.analysisInput.extractedSlots", + absent() + )) + .withRequestBody(com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath( + "$.analysisInput.requestedFieldKeys", + absent() + )) + .withRequestBody(com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath( + "$.analysisInput.workers", + absent() + )) + .withRequestBody(com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath( + "$.analysisInput.workflowConstraints", absent() )) .willReturn(jsonResponse(objectMapper.writeValueAsString(contextRequiredResponse())))); From b305b8dc82744e909f90d10c8c41a946771970da Mon Sep 17 00:00:00 2001 From: hywznn Date: Mon, 3 Aug 2026 17:10:14 +0900 Subject: [PATCH 10/10] =?UTF-8?q?refactor(ai):=20Runtime=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=20=EA=B3=84=EC=95=BD=EC=9D=84=20=EC=B5=9C=EC=86=8C=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=EB=A1=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ai-runtime-contract.md | 49 +++-------- .../application/model/AiAnalysisRequest.java | 4 +- .../application/model/AnalysisInput.java | 7 +- .../application/model/WorkerContext.java | 7 +- .../http/AiRuntimeHttpRequest.java | 57 +++++++++++++ .../http/RemoteAiRuntimeClient.java | 17 +--- .../model/AiRuntimeOutboundContractTest.java | 81 ------------------- .../http/AiRuntimeHttpRequestTest.java | 59 ++++++++++++++ .../RemoteAiRuntimeClientWireMockTest.java | 47 ++++++++++- 9 files changed, 186 insertions(+), 142 deletions(-) create mode 100644 src/main/java/com/fowoco/server/aiintegration/infrastructure/http/AiRuntimeHttpRequest.java delete mode 100644 src/test/java/com/fowoco/server/aiintegration/application/model/AiRuntimeOutboundContractTest.java create mode 100644 src/test/java/com/fowoco/server/aiintegration/infrastructure/http/AiRuntimeHttpRequestTest.java diff --git a/docs/ai-runtime-contract.md b/docs/ai-runtime-contract.md index 5c602b4..0475651 100644 --- a/docs/ai-runtime-contract.md +++ b/docs/ai-runtime-contract.md @@ -40,20 +40,16 @@ Prompt, Agent Pipeline, Provider retry와 모델 선택은 `fowoco/ai` 책임입 ```json { "requestId": "10000000-0000-0000-0000-000000000001", - "attemptId": "20000000-0000-0000-0000-000000000001", "phase": "PLAN", - "contractVersion": "1.0.0", - "requiredKnowledgeVersion": "0.2.0", - "deadlineMs": 10000, "analysisInput": { "instruction": "응웬반안 체류연장 준비해줘, EXPIRY_RENEWAL" } } ``` -PLAN에서 아직 값이 없는 `extractedSlots`, `requestedFieldKeys`, `workers`, -`workflowConstraints`는 JSON에 보내지 않습니다. Server 내부에서는 빈 collection으로 -유지하며 ANALYZE에서 실제 값이 생겼을 때만 직렬화합니다. +Runtime에는 `requestId`, `phase`, `analysisInput`만 전송합니다. PLAN에서 아직 값이 없는 +`requestedFieldKeys`와 `workers`는 JSON에 보내지 않습니다. `attemptId`, version, deadline은 +Server 내부에서만 관리합니다. Runtime이 DB 정보를 더 필요로 하면 성공 응답으로 `CONTEXT_REQUIRED`를 반환합니다. Agent는 SQL을 만들거나 DB를 직접 조회하지 않고, canonical field key만 요청합니다. @@ -93,20 +89,15 @@ Agent는 SQL을 만들거나 DB를 직접 조회하지 않고, canonical field k ## ANALYZE 요청 계약 #74가 `targetDisplayName`을 현재 사업장 안에서 한 명의 Worker로 찾고, 허용된 -`requiredFieldKeys`만 Repository로 조회합니다. 그 결과를 넣어 새로운 `attemptId`로 -ANALYZE를 호출합니다. MVP에서는 한 요청에 Worker 한 명만 허용합니다. +`requiredFieldKeys`만 Repository로 조회합니다. Server는 새 `attemptId`를 내부에 기록한 +뒤 같은 `requestId`로 ANALYZE를 호출합니다. MVP에서는 한 요청에 Worker 한 명만 허용합니다. ```json { "requestId": "10000000-0000-0000-0000-000000000001", - "attemptId": "20000000-0000-0000-0000-000000000002", "phase": "ANALYZE", - "contractVersion": "1.0.0", - "requiredKnowledgeVersion": "0.2.0", - "deadlineMs": 10000, "analysisInput": { "instruction": "응웬반안 체류연장 준비해줘, EXPIRY_RENEWAL", - "extractedSlots": {}, "requestedFieldKeys": [ "legal_name", "stay_expiry_date" @@ -114,45 +105,26 @@ ANALYZE를 호출합니다. MVP에서는 한 요청에 Worker 한 명만 허용 "workers": [ { "workerRef": "30000000-0000-0000-0000-000000000001", - "displayName": "응웬반안", - "nationalityCode": "VN", - "preferredLanguage": "vi", - "workStatus": "ACTIVE", - "stayExpiryDate": "2026-12-31", - "contractStartDate": "2026-01-01", - "contractEndDate": "2026-12-31", "requestedFields": { "legal_name": "NGUYEN VAN AN", "stay_expiry_date": "2026-12-31" } } - ], - "workflowConstraints": [ - { - "workflowId": "EXPIRY_RENEWAL", - "allowedSlotKeys": [ - "stay_expiry_date", - "contract_end_date", - "monthly_wage" - ] - } ] } } ``` - `requestId`: Server 요청과 Runtime 응답을 같은 실행으로 연결합니다. -- `attemptId`: 한 번의 `AiRuntimeClient.analyze` 호출과 정확히 하나로 대응합니다. - `phase`: 발화문을 해석하는 `PLAN`과 Server 보유정보로 결과를 만드는 `ANALYZE`를 구분합니다. -- `contractVersion`: 양쪽이 같은 JSON 계약을 사용하는지 확인합니다. -- `requiredKnowledgeVersion`: Server와 Runtime이 같은 Workflow release를 사용하게 합니다. -- `deadlineMs`: 이번 시도 전체에서 남은 실행 시간입니다. - `instruction`: HR 발화문에 선택한 태그가 있으면 `발화문, INTENT_TAG` 형식으로 붙인 단일 문자열입니다. 현재 데모에서는 가상 근로자 데이터만 사용합니다. -- `extractedSlots`: PLAN에서 Agent가 발화문으로부터 추출했던 값을 ANALYZE에도 보존합니다. - `requestedFieldKeys`: Agent가 PLAN에서 요청했던 전체 key입니다. DB에 값이 없어도 목록에는 남습니다. - `requestedFields`: Agent가 요구한 field의 원본값입니다. Server가 가진 값만 넣습니다. -- `workflowConstraints`: Knowledge projection에서 가져온 Workflow와 slot allow-list입니다. + +`attemptId`, `contractVersion`, `requiredKnowledgeVersion`, `deadlineMs`, `extractedSlots`, +`workflowConstraints`는 Server가 재시도·응답 검증·제한시간을 관리하기 위해 내부 +`AiAnalysisRequest`에 유지하지만 HTTP JSON에는 넣지 않습니다. 현재 데모에서는 PII 마스킹과 차단을 적용하지 않습니다. 실명·여권번호·전화번호 등 Agent가 문서 작성에 요구한 값은 `***`, `OOO`로 바꾸지 않고 원본으로 전달합니다. @@ -276,7 +248,8 @@ AI_RUNTIME_SERVICE_CREDENTIAL=<배포 환경 Secret> | `AI_RUNTIME_CIRCUIT_BREAKER_FAILURE_THRESHOLD` | `5` | 연속 장애 후 호출을 잠시 막는 기준 | | `AI_RUNTIME_CIRCUIT_BREAKER_OPEN_DURATION` | `30s` | 차단 후 시험 호출까지 기다리는 시간 | -요청의 `deadlineMs`와 `AI_RUNTIME_OVERALL_TIMEOUT` 중 더 짧은 값을 사용합니다. 따라서 +Server 내부 요청의 `deadlineMs`와 `AI_RUNTIME_OVERALL_TIMEOUT` 중 더 짧은 값을 HTTP +timeout으로 사용합니다. `deadlineMs` 자체는 Runtime JSON에 전송하지 않습니다. 따라서 상위 AiRun이 허용한 시간보다 오래 기다리지 않습니다. ## 장애가 발생하면 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 index 1c4fc4b..84ba378 100644 --- a/src/main/java/com/fowoco/server/aiintegration/application/model/AiAnalysisRequest.java +++ b/src/main/java/com/fowoco/server/aiintegration/application/model/AiAnalysisRequest.java @@ -4,7 +4,9 @@ import java.util.UUID; /** - * Provider-neutral body for one Server-managed PLAN or ANALYZE attempt. + * Server-internal command for one managed PLAN or ANALYZE attempt. + * + *

The HTTP Adapter selects the smaller Runtime payload from this command.

*/ public record AiAnalysisRequest( UUID requestId, diff --git a/src/main/java/com/fowoco/server/aiintegration/application/model/AnalysisInput.java b/src/main/java/com/fowoco/server/aiintegration/application/model/AnalysisInput.java index 59b8d57..f9a6d34 100644 --- a/src/main/java/com/fowoco/server/aiintegration/application/model/AnalysisInput.java +++ b/src/main/java/com/fowoco/server/aiintegration/application/model/AnalysisInput.java @@ -6,9 +6,10 @@ import java.util.Objects; /** - * HR instruction plus phase-specific context sent to the AI Runtime. - * PLAN keeps context collections empty; ANALYZE preserves extracted and requested fields and - * contains one Server-resolved Worker. + * HR instruction plus Server-managed phase context. + * + *

PLAN keeps context collections empty. ANALYZE preserves the context needed for validation, + * while the HTTP Adapter transmits only requested field keys and resolved Worker values.

*/ @JsonInclude(JsonInclude.Include.NON_EMPTY) public record AnalysisInput( diff --git a/src/main/java/com/fowoco/server/aiintegration/application/model/WorkerContext.java b/src/main/java/com/fowoco/server/aiintegration/application/model/WorkerContext.java index ff2935f..e2b6a21 100644 --- a/src/main/java/com/fowoco/server/aiintegration/application/model/WorkerContext.java +++ b/src/main/java/com/fowoco/server/aiintegration/application/model/WorkerContext.java @@ -6,10 +6,11 @@ import java.util.UUID; /** - * Worker data sent to the AI Runtime for the current fake-data demo. + * Server-managed Worker context for the current fake-data demo. * - *

{@code requestedFields} carries the original values requested by the Agent. Service - * credentials, JWTs, passwords, and Worker Link tokens must never be placed in this map.

+ *

The HTTP Adapter sends only {@code workerRef} and {@code requestedFields}. The remaining + * fields stay inside Server for response validation. Service credentials, JWTs, passwords, and + * Worker Link tokens must never be placed in {@code requestedFields}.

*/ public record WorkerContext( UUID workerRef, diff --git a/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/AiRuntimeHttpRequest.java b/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/AiRuntimeHttpRequest.java new file mode 100644 index 0000000..1257c12 --- /dev/null +++ b/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/AiRuntimeHttpRequest.java @@ -0,0 +1,57 @@ +package com.fowoco.server.aiintegration.infrastructure.http; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fowoco.server.aiintegration.application.model.AiAnalysisPhase; +import com.fowoco.server.aiintegration.application.model.AiAnalysisRequest; +import com.fowoco.server.aiintegration.application.model.AnalysisInput; +import com.fowoco.server.aiintegration.application.model.WorkerContext; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * Minimal JSON body sent across the Server-to-Runtime boundary. + * + *

Attempt identifiers, version requirements, deadlines, extracted slots, and workflow + * constraints remain Server-owned metadata. They are intentionally not serialized here.

+ */ +record AiRuntimeHttpRequest( + UUID requestId, + AiAnalysisPhase phase, + HttpAnalysisInput analysisInput +) { + + static AiRuntimeHttpRequest from(AiAnalysisRequest request) { + return new AiRuntimeHttpRequest( + request.requestId(), + request.phase(), + HttpAnalysisInput.from(request.analysisInput()) + ); + } + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + record HttpAnalysisInput( + String instruction, + List requestedFieldKeys, + List workers + ) { + + static HttpAnalysisInput from(AnalysisInput input) { + return new HttpAnalysisInput( + input.instruction(), + input.requestedFieldKeys(), + input.workers().stream().map(HttpWorkerContext::from).toList() + ); + } + } + + record HttpWorkerContext( + UUID workerRef, + Map requestedFields + ) { + + static HttpWorkerContext from(WorkerContext worker) { + return new HttpWorkerContext(worker.workerRef(), worker.requestedFields()); + } + } +} diff --git a/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiRuntimeClient.java b/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiRuntimeClient.java index 3bf8737..ed26925 100644 --- a/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiRuntimeClient.java +++ b/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiRuntimeClient.java @@ -93,8 +93,7 @@ public AiAnalysisResponse analyze(AiAnalysisRequest request, AiRuntimeCallContex circuitPermitAcquired = true; long remainingMillis = remainingMillis(request, startedNanos); - AiAnalysisRequest outboundRequest = withRemainingDeadline(request, remainingMillis); - byte[] requestBody = serialize(outboundRequest); + byte[] requestBody = serialize(AiRuntimeHttpRequest.from(request)); remainingMillis = remainingMillis(request, startedNanos); HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(endpoint) @@ -179,7 +178,7 @@ private AiAnalysisResponse decodeResponse(HttpResponse response) { } } - private byte[] serialize(AiAnalysisRequest request) { + private byte[] serialize(AiRuntimeHttpRequest request) { try { return objectMapper.writeValueAsBytes(request); } catch (JacksonException exception) { @@ -259,18 +258,6 @@ private long remainingMillis(AiAnalysisRequest request, long startedNanos) { return remainingMillis; } - private AiAnalysisRequest withRemainingDeadline(AiAnalysisRequest request, long remainingMillis) { - return new AiAnalysisRequest( - request.requestId(), - request.attemptId(), - request.phase(), - request.contractVersion(), - request.requiredKnowledgeVersion(), - remainingMillis, - request.analysisInput() - ); - } - private static Throwable unwrap(Throwable throwable) { Throwable current = throwable; while ((current instanceof ExecutionException 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 deleted file mode 100644 index e4dae92..0000000 --- a/src/test/java/com/fowoco/server/aiintegration/application/model/AiRuntimeOutboundContractTest.java +++ /dev/null @@ -1,81 +0,0 @@ -package com.fowoco.server.aiintegration.application.model; - -import static com.fowoco.server.aiintegration.support.AiRuntimeContractFixture.validRequest; -import static com.fowoco.server.aiintegration.support.AiRuntimeContractFixture.validPlanRequest; -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 planJsonContainsCombinedInstructionAsTheOnlyBusinessInput() { - JsonNode json = objectMapper.valueToTree(validPlanRequest()); - JsonNode input = json.get("analysisInput"); - - assertThat(json.get("phase").textValue()).isEqualTo("PLAN"); - assertThat(input.get("instruction").textValue()) - .isEqualTo("응웬반안 체류연장 준비해줘, EXPIRY_RENEWAL"); - assertThat(input.has("intentHint")).isFalse(); - assertThat(input.has("extractedSlots")).isFalse(); - assertThat(input.has("requestedFieldKeys")).isFalse(); - assertThat(input.has("workers")).isFalse(); - assertThat(input.has("workflowConstraints")).isFalse(); - assertThat(input.properties().stream().map(java.util.Map.Entry::getKey).toList()) - .containsExactly("instruction"); - } - - @Test - void outboundJsonContainsOriginalDemoDataWithoutServiceCredentials() throws Exception { - JsonNode json = objectMapper.valueToTree(validRequest()); - JsonNode input = json.get("analysisInput"); - JsonNode worker = input.get("workers").get(0); - - assertThat(json.properties().stream().map(java.util.Map.Entry::getKey).toList()) - .containsExactlyInAnyOrder( - "requestId", - "attemptId", - "phase", - "contractVersion", - "requiredKnowledgeVersion", - "deadlineMs", - "analysisInput" - ); - assertThat(worker.properties().stream().map(java.util.Map.Entry::getKey).toList()) - .containsExactlyInAnyOrder( - "workerRef", - "displayName", - "nationalityCode", - "preferredLanguage", - "workStatus", - "stayExpiryDate", - "contractStartDate", - "contractEndDate", - "requestedFields" - ); - assertThat(input.get("instruction").textValue()) - .contains("응웬반안", "010-1234-5678"); - assertThat(input.get("instruction").textValue()).endsWith(", EXPIRY_RENEWAL"); - assertThat(input.has("intentHint")).isFalse(); - assertThat(input.get("extractedSlots").get("document_type").textValue()) - .isEqualTo("STAY_EXTENSION"); - assertThat(input.get("requestedFieldKeys")).hasSize(4); - assertThat(worker.get("requestedFields").get("legal_name").textValue()) - .isEqualTo("NGUYEN VAN AN"); - assertThat(worker.get("requestedFields").get("passport_number").textValue()) - .isEqualTo("M12345678"); - assertThat(worker.get("requestedFields").get("email").textValue()) - .isEqualTo("worker@example.com"); - assertThat(json.toString().toLowerCase()) - .doesNotContain( - "token", - "authorization", - "password", - "api_key" - ); - } -} diff --git a/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/AiRuntimeHttpRequestTest.java b/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/AiRuntimeHttpRequestTest.java new file mode 100644 index 0000000..8cca9bd --- /dev/null +++ b/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/AiRuntimeHttpRequestTest.java @@ -0,0 +1,59 @@ +package com.fowoco.server.aiintegration.infrastructure.http; + +import static com.fowoco.server.aiintegration.support.AiRuntimeContractFixture.validPlanRequest; +import static com.fowoco.server.aiintegration.support.AiRuntimeContractFixture.validRequest; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; +import org.junit.jupiter.api.Test; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +class AiRuntimeHttpRequestTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void planJsonContainsOnlyRequestIdPhaseAndInstruction() { + JsonNode json = objectMapper.valueToTree(AiRuntimeHttpRequest.from(validPlanRequest())); + JsonNode input = json.get("analysisInput"); + + assertThat(fieldNames(json)) + .containsExactlyInAnyOrder("requestId", "phase", "analysisInput"); + assertThat(json.get("phase").textValue()).isEqualTo("PLAN"); + assertThat(fieldNames(input)).containsExactly("instruction"); + assertThat(input.get("instruction").textValue()) + .isEqualTo("응웬반안 체류연장 준비해줘, EXPIRY_RENEWAL"); + } + + @Test + void analyzeJsonContainsOnlyRequestedWorkerFields() { + JsonNode json = objectMapper.valueToTree(AiRuntimeHttpRequest.from(validRequest())); + JsonNode input = json.get("analysisInput"); + JsonNode worker = input.get("workers").get(0); + + assertThat(fieldNames(json)) + .containsExactlyInAnyOrder("requestId", "phase", "analysisInput"); + assertThat(fieldNames(input)) + .containsExactlyInAnyOrder("instruction", "requestedFieldKeys", "workers"); + assertThat(fieldNames(worker)) + .containsExactlyInAnyOrder("workerRef", "requestedFields"); + assertThat(worker.get("requestedFields").get("legal_name").textValue()) + .isEqualTo("NGUYEN VAN AN"); + assertThat(json.toString().toLowerCase()) + .doesNotContain( + "attemptid", + "contractversion", + "requiredknowledgeversion", + "deadlinems", + "token", + "authorization", + "password", + "api_key" + ); + } + + private java.util.List fieldNames(JsonNode node) { + return node.properties().stream().map(Map.Entry::getKey).toList(); + } +} diff --git a/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiRuntimeClientWireMockTest.java b/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiRuntimeClientWireMockTest.java index 21f989a..9aafff5 100644 --- a/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiRuntimeClientWireMockTest.java +++ b/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteAiRuntimeClientWireMockTest.java @@ -77,12 +77,41 @@ void sendsCanonicalHeadersAndCamelCaseBodyThenParsesResponse() throws Exception equalTo("ANALYZE") )) .withRequestBody(com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath( - "$.analysisInput.workers[0].stayExpiryDate" + "$.analysisInput.workers[0].stayExpiryDate", + absent() )) .withRequestBody(com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath( "$.analysisInput.workers[0].requestedFields.legal_name", equalTo("NGUYEN VAN AN") )) + .withRequestBody(com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath( + "$.attemptId", + absent() + )) + .withRequestBody(com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath( + "$.contractVersion", + absent() + )) + .withRequestBody(com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath( + "$.requiredKnowledgeVersion", + absent() + )) + .withRequestBody(com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath( + "$.deadlineMs", + absent() + )) + .withRequestBody(com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath( + "$.analysisInput.extractedSlots", + absent() + )) + .withRequestBody(com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath( + "$.analysisInput.workflowConstraints", + absent() + )) + .withRequestBody(com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath( + "$.analysisInput.workers[0].displayName", + absent() + )) .withRequestBody(com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath( "$.request_id", absent() @@ -127,6 +156,22 @@ void sendsInstructionOnlyPlanAndParsesContextRequirement() throws Exception { "$.analysisInput.workflowConstraints", absent() )) + .withRequestBody(com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath( + "$.attemptId", + absent() + )) + .withRequestBody(com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath( + "$.contractVersion", + absent() + )) + .withRequestBody(com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath( + "$.requiredKnowledgeVersion", + absent() + )) + .withRequestBody(com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath( + "$.deadlineMs", + absent() + )) .willReturn(jsonResponse(objectMapper.writeValueAsString(contextRequiredResponse())))); AiAnalysisResponse response = client(1_048_576, 8, 5, Duration.ofSeconds(30))