M5 AP-003 OpenAI-kompatiblen KI-HTTP-Adapter mit wirksamer Konfiguration

implementiert
This commit is contained in:
2026-04-07 00:20:09 +02:00
parent 9ea6c3aaa5
commit 3a772c20c0
3 changed files with 650 additions and 0 deletions
@@ -0,0 +1,295 @@
package de.gecheckt.pdf.umbenenner.adapter.out.ai;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Objects;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.json.JSONObject;
import de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationPort;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationResult;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationSuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationTechnicalFailure;
import de.gecheckt.pdf.umbenenner.domain.model.AiRawResponse;
import de.gecheckt.pdf.umbenenner.domain.model.AiRequestRepresentation;
/**
* Adapter implementing OpenAI-compatible HTTP communication for AI service invocation.
* <p>
* This adapter:
* <ul>
* <li>Translates an abstract {@link AiRequestRepresentation} into an OpenAI Chat
* Completions API request</li>
* <li>Configures HTTP connection, timeout, and authentication from the startup configuration</li>
* <li>Executes the HTTP request against the configured AI endpoint</li>
* <li>Distinguishes between successful HTTP responses (200) and technical failures
* (timeout, unreachable, connection error, etc.)</li>
* <li>Returns the raw response body as-is for Application-layer parsing and validation</li>
* <li>Classifies and encodes technical failures for retry decision-making</li>
* </ul>
* <p>
* <strong>Configuration:</strong>
* <ul>
* <li>{@code apiBaseUrl} — the HTTP(S) base URL of the AI service endpoint</li>
* <li>{@code apiModel} — the model identifier requested from the AI service</li>
* <li>{@code apiTimeoutSeconds} — connection and read timeout in seconds</li>
* <li>{@code apiKey} — the authentication token (already resolved from environment
* variable {@code PDF_UMBENENNER_API_KEY} or property {@code api.key},
* environment variable takes precedence)</li>
* </ul>
* <p>
* <strong>HTTP request structure:</strong>
* The adapter sends a POST request to the endpoint {@code {apiBaseUrl}/v1/chat/completions}
* with:
* <ul>
* <li>Authorization header containing the API key</li>
* <li>Content-Type application/json</li>
* <li>JSON body containing:
* <ul>
* <li>{@code model} — the configured model name</li>
* <li>{@code messages} — array with system role (prompt) and user role (document text)</li>
* <li>Optional fields like {@code temperature} for determinism (if desired)</li>
* </ul>
* </li>
* </ul>
* <p>
* <strong>Response handling:</strong>
* <ul>
* <li><strong>HTTP 200:</strong> Returns {@link AiInvocationSuccess} with the raw response body,
* even if the body is invalid JSON or semantically problematic. The Application layer
* is responsible for parsing and validating content.</li>
* <li><strong>HTTP non-200:</strong> Treated as a technical failure. The response body may
* contain an error message, but this is logged for debugging; the client treats it as
* a transient communication failure.</li>
* </ul>
* <p>
* <strong>Technical error classification:</strong>
* The following are classified as {@link AiInvocationTechnicalFailure}:
* <ul>
* <li>Connection timeout</li>
* <li>Read timeout</li>
* <li>Endpoint unreachable (connection refused, DNS failure, etc.)</li>
* <li>Interrupted IO during HTTP communication</li>
* <li>HTTP response with non-2xx status code</li>
* <li>Any other transport-level exception</li>
* </ul>
* <p>
* <strong>Non-goals:</strong>
* <ul>
* <li>Response body parsing or validation — the Application layer owns this</li>
* <li>Retry logic — this adapter executes a single request only</li>
* <li>Functional error classification (invalid title, unparseable date) — above adapter scope</li>
* </ul>
*/
public class OpenAiHttpAdapter implements AiInvocationPort {
private static final Logger LOG = LogManager.getLogger(OpenAiHttpAdapter.class);
private static final String CHAT_COMPLETIONS_ENDPOINT = "/v1/chat/completions";
private static final String CONTENT_TYPE = "application/json";
private static final String AUTHORIZATION_HEADER = "Authorization";
private static final String BEARER_PREFIX = "Bearer ";
private final HttpClient httpClient;
private final URI apiBaseUrl;
private final String apiModel;
private final String apiKey;
/**
* Creates an adapter with configuration from startup configuration.
* <p>
* The adapter initializes an HTTP client with the configured timeout and creates
* the endpoint URL from the base URL. Configuration values are validated for
* null/empty during initialization.
*
* @param config the startup configuration containing API settings; must not be null
* @throws NullPointerException if config is null
* @throws IllegalArgumentException if API base URL or model is missing/empty
*/
public OpenAiHttpAdapter(StartConfiguration config) {
Objects.requireNonNull(config, "config must not be null");
if (config.apiBaseUrl() == null) {
throw new IllegalArgumentException("API base URL must not be null");
}
if (config.apiModel() == null || config.apiModel().isBlank()) {
throw new IllegalArgumentException("API model must not be null or empty");
}
this.apiBaseUrl = config.apiBaseUrl();
this.apiModel = config.apiModel();
this.apiKey = config.apiKey() != null ? config.apiKey() : "";
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(config.apiTimeoutSeconds()))
.build();
LOG.debug("OpenAiHttpAdapter initialized with base URL: {}, model: {}, timeout: {}s",
apiBaseUrl, apiModel, config.apiTimeoutSeconds());
}
/**
* Invokes the AI service with the given request.
* <p>
* Constructs an OpenAI Chat Completions API request from the request representation,
* executes it against the configured endpoint, and returns either a successful
* response or a classified technical failure.
* <p>
* The request representation contains:
* <ul>
* <li>Prompt content and identifier (for audit)</li>
* <li>Document text (already limited to max characters by Application)</li>
* <li>Exact character count sent to the AI</li>
* </ul>
* <p>
* These are formatted as system and user messages for the Chat Completions API.
*
* @param request the AI request with prompt and document text; must not be null
* @return an {@link AiInvocationResult} encoding either success (with raw response body)
* or a technical failure with classified reason
* @throws NullPointerException if request is null
*/
@Override
public AiInvocationResult invoke(AiRequestRepresentation request) {
Objects.requireNonNull(request, "request must not be null");
try {
HttpRequest httpRequest = buildRequest(request);
HttpResponse<String> response = executeRequest(httpRequest);
if (response.statusCode() == 200) {
return new AiInvocationSuccess(request, new AiRawResponse(response.body()));
} else {
String reason = "HTTP_" + response.statusCode();
String message = "AI service returned status " + response.statusCode();
LOG.warn("AI invocation returned non-200 status: {}", response.statusCode());
return new AiInvocationTechnicalFailure(request, reason, message);
}
} catch (java.net.http.HttpTimeoutException e) {
String message = "HTTP timeout after " + e.getClass().getSimpleName();
LOG.warn("AI invocation timeout: {}", message);
return new AiInvocationTechnicalFailure(request, "TIMEOUT", message);
} catch (java.net.ConnectException e) {
String message = "Failed to connect to endpoint: " + e.getMessage();
LOG.warn("AI invocation connection error: {}", message);
return new AiInvocationTechnicalFailure(request, "CONNECTION_ERROR", message);
} catch (java.net.UnknownHostException e) {
String message = "Endpoint hostname not resolvable: " + e.getMessage();
LOG.warn("AI invocation DNS error: {}", message);
return new AiInvocationTechnicalFailure(request, "DNS_ERROR", message);
} catch (java.io.IOException e) {
String message = "IO error during AI invocation: " + e.getMessage();
LOG.warn("AI invocation IO error: {}", message);
return new AiInvocationTechnicalFailure(request, "IO_ERROR", message);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
String message = "AI invocation interrupted: " + e.getMessage();
LOG.warn("AI invocation interrupted: {}", message);
return new AiInvocationTechnicalFailure(request, "INTERRUPTED", message);
} catch (Exception e) {
String message = "Unexpected error during AI invocation: " + e.getClass().getSimpleName()
+ " - " + e.getMessage();
LOG.error("Unexpected error in AI invocation", e);
return new AiInvocationTechnicalFailure(request, "UNEXPECTED_ERROR", message);
}
}
/**
* Builds an OpenAI Chat Completions API request from the request representation.
* <p>
* Constructs:
* <ul>
* <li>Endpoint URL: {@code {apiBaseUrl}/v1/chat/completions}</li>
* <li>Headers: Authorization with Bearer token, Content-Type application/json</li>
* <li>Body: JSON with model, messages (system = prompt, user = document text)</li>
* </ul>
*
* @param request the request representation with prompt and document text
* @return an {@link HttpRequest} ready to send
*/
private HttpRequest buildRequest(AiRequestRepresentation request) {
URI endpoint = buildEndpointUri();
String requestBody = buildJsonRequestBody(request);
return HttpRequest.newBuilder(endpoint)
.header("Content-Type", CONTENT_TYPE)
.header(AUTHORIZATION_HEADER, BEARER_PREFIX + apiKey)
.POST(HttpRequest.BodyPublishers.ofString(requestBody))
.timeout(Duration.ofSeconds(30)) // Additional timeout on request builder
.build();
}
/**
* Composes the endpoint URI from the configured base URL.
* <p>
* Resolves {@code {apiBaseUrl}/v1/chat/completions}.
*
* @return the complete endpoint URI
*/
private URI buildEndpointUri() {
String endpointPath = apiBaseUrl.getPath().replaceAll("/$", "") + CHAT_COMPLETIONS_ENDPOINT;
return URI.create(apiBaseUrl.getScheme() + "://" +
apiBaseUrl.getHost() +
(apiBaseUrl.getPort() > 0 ? ":" + apiBaseUrl.getPort() : "") +
endpointPath);
}
/**
* Builds the JSON request body for the OpenAI Chat Completions API.
* <p>
* The body contains:
* <ul>
* <li>{@code model} — the configured model name</li>
* <li>{@code messages} — array with system and user roles</li>
* <li>{@code temperature} — 0.0 for deterministic output</li>
* </ul>
* <p>
* The prompt is sent as system message; the document text as user message.
*
* @param request the request with prompt and document text
* @return JSON string ready to send in HTTP body
*/
private String buildJsonRequestBody(AiRequestRepresentation request) {
JSONObject body = new JSONObject();
body.put("model", apiModel);
body.put("temperature", 0.0);
JSONObject systemMessage = new JSONObject();
systemMessage.put("role", "system");
systemMessage.put("content", request.promptContent());
JSONObject userMessage = new JSONObject();
userMessage.put("role", "user");
userMessage.put("content", request.documentText().substring(0, request.sentCharacterCount()));
body.put("messages", new org.json.JSONArray()
.put(systemMessage)
.put(userMessage));
return body.toString();
}
/**
* Executes the HTTP request and returns the response.
* <p>
* Uses the HTTP client configured with the startup timeout to send the request
* and receive the full response body.
*
* @param httpRequest the HTTP request to execute
* @return the HTTP response with status code and body
* @throws java.net.http.HttpTimeoutException if the request times out
* @throws java.net.ConnectException if connection fails
* @throws java.io.IOException on other IO errors
* @throws InterruptedException if the request is interrupted
*/
private HttpResponse<String> executeRequest(HttpRequest httpRequest)
throws java.io.IOException, InterruptedException {
return httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString());
}
}
@@ -0,0 +1,62 @@
/**
* Outbound adapter for AI service invocation over OpenAI-compatible HTTP.
* <p>
* <strong>Responsibility:</strong>
* This package encapsulates all HTTP communication, authentication, and transport-level
* configuration for invoking an AI service. It translates between the abstract
* {@link de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationPort} and the
* concrete OpenAI Chat Completions API (or compatible endpoints).
* <p>
* <strong>Architectural boundary:</strong>
* <ul>
* <li>Input: {@link de.gecheckt.pdf.umbenenner.domain.model.AiRequestRepresentation}
* containing prompt, document text, and metadata</li>
* <li>Output: {@link de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationResult}
* encoding either a successful HTTP response or a classified technical failure</li>
* </ul>
* <p>
* <strong>What is encapsulated here (NOT exposed to Application/Domain):</strong>
* <ul>
* <li>HTTP client library details</li>
* <li>Authentication header construction</li>
* <li>JSON serialization/deserialization of request/response</li>
* <li>Endpoint URL composition</li>
* <li>Timeout and connection configuration</li>
* <li>Error classification for technical failures (timeout, network, etc.)</li>
* <li>OpenAI Chat Completions API structure</li>
* </ul>
* <p>
* <strong>What is NOT handled here (delegated to Application layer):</strong>
* <ul>
* <li>JSON parsing of the response body into domain objects</li>
* <li>Validation of title, date, or reasoning fields</li>
* <li>Retry logic or retry classification</li>
* <li>Persistence of responses or request/response history</li>
* </ul>
* <p>
* <strong>Technical error classification:</strong>
* The adapter recognizes these as technical failures (retryable):
* <ul>
* <li>Connection timeout</li>
* <li>Read timeout</li>
* <li>Endpoint unreachable (connection refused, DNS failure, etc.)</li>
* <li>Interrupted IO during request/response</li>
* <li>Other transport-level exceptions</li>
* </ul>
* <p>
* A successful HTTP 200 response with unparseable or semantically invalid JSON is
* <strong>not</strong> a technical failure; it is invocation success with a
* problematic response body, which the Application layer will detect and classify.
* <p>
* <strong>Configuration usage:</strong>
* The adapter receives startup configuration containing:
* <ul>
* <li>{@code apiBaseUrl} — the base URL of the AI endpoint (e.g., https://api.openai.com)</li>
* <li>{@code apiModel} — the model name to request (e.g., gpt-4, local-llm)</li>
* <li>{@code apiTimeoutSeconds} — connection and read timeout in seconds</li>
* <li>{@code apiKey} — the authentication token, already resolved from env var or properties</li>
* </ul>
* <p>
* These are not merely documented; they are <strong>actively used</strong> in the HTTP request.
*/
package de.gecheckt.pdf.umbenenner.adapter.out.ai;