diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteDocumentRecordRepositoryAdapter.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteDocumentRecordRepositoryAdapter.java index aa7c108..d99a262 100644 --- a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteDocumentRecordRepositoryAdapter.java +++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteDocumentRecordRepositoryAdapter.java @@ -103,7 +103,8 @@ public class SqliteDocumentRecordRepositoryAdapter implements DocumentRecordRepo return switch (record.overallStatus()) { case SUCCESS -> new DocumentTerminalSuccess(record); case FAILED_FINAL -> new DocumentTerminalFinalFailure(record); - case PROCESSING, FAILED_RETRYABLE, SKIPPED_ALREADY_PROCESSED, SKIPPED_FINAL_FAILURE -> + case READY_FOR_AI, PROPOSAL_READY, PROCESSING, FAILED_RETRYABLE, + SKIPPED_ALREADY_PROCESSED, SKIPPED_FINAL_FAILURE -> new DocumentKnownProcessable(record); }; } else { diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/AiInvocationPort.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/AiInvocationPort.java new file mode 100644 index 0000000..1644232 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/AiInvocationPort.java @@ -0,0 +1,77 @@ +package de.gecheckt.pdf.umbenenner.application.port.out; + +import de.gecheckt.pdf.umbenenner.domain.model.AiRequestRepresentation; + +/** + * Outbound port for invoking an AI service over an OpenAI-compatible HTTP boundary. + *

+ * This interface abstracts AI service communication, allowing the Application layer + * to orchestrate AI-based naming without knowing about HTTP, authentication, or + * provider-specific details. + *

+ * Design principles: + *

+ *

+ * Adapter responsibilities: + *

+ *

+ * Non-goals of this port: + *

+ *

+ * OpenAI compatibility: The adapter must support the OpenAI Chat + * Completions API or a compatible endpoint. The {@code AiRequestRepresentation} + * contains the prompt and document text; the adapter is responsible for formatting + * these as needed (e.g., system message + user message in the Chat API). + * + * @since M5 + */ +public interface AiInvocationPort { + + /** + * Invokes an AI service with the given request representation. + *

+ * This method sends a request to the configured AI endpoint and returns the result. + * The request contains both the prompt and the document text, deterministically + * composed by the Application layer. + *

+ * Outcome distinction: + *

+ * + * @param request the complete request to send to the AI service; never null + * @return an {@link AiInvocationResult} encoding either: + * + * @throws NullPointerException if request is null + * + * @see AiInvocationSuccess + * @see AiInvocationTechnicalFailure + */ + AiInvocationResult invoke(AiRequestRepresentation request); +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/AiInvocationResult.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/AiInvocationResult.java new file mode 100644 index 0000000..335f75a --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/AiInvocationResult.java @@ -0,0 +1,28 @@ +package de.gecheckt.pdf.umbenenner.application.port.out; + +/** + * Sealed interface representing the outcome of invoking an AI service. + *

+ * Implementations allow the Application layer to distinguish between: + *

+ *

+ * Permitted implementations: + *

+ *

+ * Critical distinction: A successful invocation means the HTTP request + * was sent and a response was received, but the response content may still be unparseable + * or semantically invalid. This is crucial for retry logic: a technical HTTP success + * with unparseable JSON is different from a timeout or network error. + * + * @since M5 + */ +public sealed interface AiInvocationResult + permits AiInvocationSuccess, AiInvocationTechnicalFailure { +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/AiInvocationSuccess.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/AiInvocationSuccess.java new file mode 100644 index 0000000..a0d9d0a --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/AiInvocationSuccess.java @@ -0,0 +1,51 @@ +package de.gecheckt.pdf.umbenenner.application.port.out; + +import de.gecheckt.pdf.umbenenner.domain.model.AiRawResponse; +import de.gecheckt.pdf.umbenenner.domain.model.AiRequestRepresentation; +import java.util.Objects; + +/** + * Represents successful HTTP communication with an AI service. + *

+ * The HTTP request was sent and a response body was received. This indicates + * technical success of the communication, but does NOT guarantee that the response + * content is valid, parseable, or functionally usable. + *

+ * Field semantics: + *

+ *

+ * The Application layer is responsible for: + *

+ *

+ * Persistence: Both request and response are stored in the + * processing attempt history for debugging and audit. + * + * @param request the AI request that was sent; never null + * @param rawResponse the uninterpreted response body; never null (but may be empty) + * + * @since M5 + */ +public record AiInvocationSuccess( + AiRequestRepresentation request, + AiRawResponse rawResponse) implements AiInvocationResult { + + /** + * Compact constructor validating mandatory fields. + * + * @throws NullPointerException if either field is null + */ + public AiInvocationSuccess { + Objects.requireNonNull(request, "request must not be null"); + Objects.requireNonNull(rawResponse, "rawResponse must not be null"); + } +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/AiInvocationTechnicalFailure.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/AiInvocationTechnicalFailure.java new file mode 100644 index 0000000..c288abb --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/AiInvocationTechnicalFailure.java @@ -0,0 +1,53 @@ +package de.gecheckt.pdf.umbenenner.application.port.out; + +import de.gecheckt.pdf.umbenenner.domain.model.AiRequestRepresentation; +import java.util.Objects; + +/** + * Represents a technical failure during AI service invocation. + *

+ * The HTTP request could not be sent, or no valid response body was received. + * This covers network errors, timeouts, endpoint unreachability, connection failures, + * and other infrastructure-level problems. + *

+ * Field semantics: + *

+ *

+ * Retry semantics: Technical failures are retryable. The Application + * layer will record this as a transient error, and the document may be retried in + * a later batch run up to the configured maximum transient-error count. + *

+ * Distinction from functional errors: A 200 OK response with an + * invalid JSON body is NOT a technical failure; it's an invocation success that + * contains a functional error. Only communication/transport errors are classified here. + * + * @param request the request that was attempted (may not have been successfully sent); + * never null + * @param failureReason classification of the error type; never null (may be empty) + * @param failureMessage human-readable error description; never null (may be empty) + * + * @since M5 + */ +public record AiInvocationTechnicalFailure( + AiRequestRepresentation request, + String failureReason, + String failureMessage) implements AiInvocationResult { + + /** + * Compact constructor validating mandatory fields. + * + * @throws NullPointerException if any field is null + */ + public AiInvocationTechnicalFailure { + Objects.requireNonNull(request, "request must not be null"); + Objects.requireNonNull(failureReason, "failureReason must not be null"); + Objects.requireNonNull(failureMessage, "failureMessage must not be null"); + } +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/PromptLoadingFailure.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/PromptLoadingFailure.java new file mode 100644 index 0000000..62334de --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/PromptLoadingFailure.java @@ -0,0 +1,41 @@ +package de.gecheckt.pdf.umbenenner.application.port.out; + +import java.util.Objects; + +/** + * Represents failure to load an external prompt template. + *

+ * The prompt could not be obtained from the configured external source, + * or the loaded content was technically invalid (e.g., empty after trimming). + *

+ * Field semantics: + *

+ *

+ * This is a technical failure, not a validation error, and typically prevents + * the batch run from proceeding further (may lead to a {@code PROCESSING} status + * treated as {@code FAILED_RETRYABLE}). + * + * @param failureReason classification of the failure (non-null, may be empty) + * @param failureMessage human-readable failure description (non-null, may be empty) + * + * @since M5 + */ +public record PromptLoadingFailure( + String failureReason, + String failureMessage) implements PromptLoadingResult { + + /** + * Compact constructor validating mandatory fields. + * + * @throws NullPointerException if either field is null + */ + public PromptLoadingFailure { + Objects.requireNonNull(failureReason, "failureReason must not be null"); + Objects.requireNonNull(failureMessage, "failureMessage must not be null"); + } +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/PromptLoadingResult.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/PromptLoadingResult.java new file mode 100644 index 0000000..fe83d81 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/PromptLoadingResult.java @@ -0,0 +1,19 @@ +package de.gecheckt.pdf.umbenenner.application.port.out; + +/** + * Sealed interface representing the outcome of loading an external prompt template. + *

+ * Implementations allow the Application layer to distinguish between a successful + * prompt load and various failure scenarios without using exceptions. + *

+ * Permitted implementations: + *

+ * + * @since M5 + */ +public sealed interface PromptLoadingResult + permits PromptLoadingSuccess, PromptLoadingFailure { +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/PromptLoadingSuccess.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/PromptLoadingSuccess.java new file mode 100644 index 0000000..8e77989 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/PromptLoadingSuccess.java @@ -0,0 +1,44 @@ +package de.gecheckt.pdf.umbenenner.application.port.out; + +import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier; +import java.util.Objects; + +/** + * Represents successful loading of an external prompt template. + *

+ * The prompt content and a stable identifier for the prompt have both been + * successfully obtained from the configured external source. + *

+ * Field semantics: + *

+ *

+ * The identifier is crucial for historical traceability: each processing attempt + * records which prompt was used, allowing later investigation of why a particular + * decision was made. + * + * @param promptIdentifier stable identifier for this prompt version; never null + * @param promptContent the prompt template text; never null + * + * @since M5 + */ +public record PromptLoadingSuccess( + PromptIdentifier promptIdentifier, + String promptContent) implements PromptLoadingResult { + + /** + * Compact constructor validating mandatory fields. + * + * @throws NullPointerException if either field is null + */ + public PromptLoadingSuccess { + Objects.requireNonNull(promptIdentifier, "promptIdentifier must not be null"); + Objects.requireNonNull(promptContent, "promptContent must not be null"); + } +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/PromptPort.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/PromptPort.java new file mode 100644 index 0000000..4d21d44 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/PromptPort.java @@ -0,0 +1,58 @@ +package de.gecheckt.pdf.umbenenner.application.port.out; + +/** + * Outbound port for loading external prompt templates. + *

+ * This interface abstracts the loading of prompt content from external sources + * (files, resources, databases, etc.), allowing the Application layer to remain + * independent of how or where prompts are stored. + *

+ * Design principles: + *

+ *

+ * Adapter responsibilities: + *

+ *

+ * Non-goals of this port: + *

+ * + * @since M5 + */ +public interface PromptPort { + + /** + * Loads the configured external prompt template. + *

+ * This method is called once per batch run to obtain the current prompt. + * The prompt content and its stable identifier are returned together. + *

+ * If loading fails for any reason (file not found, I/O error, content validation), + * a {@link PromptLoadingFailure} is returned rather than throwing an exception. + * + * @return a {@link PromptLoadingResult} encoding either: + *

+ * + * @see PromptLoadingSuccess + * @see PromptLoadingFailure + */ + PromptLoadingResult loadPrompt(); +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/package-info.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/package-info.java index 88bba34..f0b2a73 100644 --- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/package-info.java +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/package-info.java @@ -22,6 +22,14 @@ * — Extract text content and page count from a single PDF * *

+ * AI-based naming ports (M5+): + *

+ *

* Persistence and fingerprinting ports: *

*

* Exception types: diff --git a/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/model/AiErrorClassification.java b/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/model/AiErrorClassification.java new file mode 100644 index 0000000..1a8aac6 --- /dev/null +++ b/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/model/AiErrorClassification.java @@ -0,0 +1,47 @@ +package de.gecheckt.pdf.umbenenner.domain.model; + +/** + * Classification of AI-related errors into technical vs. functional categories. + *

+ * This enumeration distinguishes between two fundamental error types that occur + * during AI-based naming proposal generation: + *

+ *

+ * The classification determines retry behavior: technical errors may be retried in + * a later run, while functional errors are subject to the deterministic failure rule + * (first occurrence retryable, second occurrence final). + * + * @since M5 + */ +public enum AiErrorClassification { + + /** + * A technical infrastructure or communication failure occurred. + *

+ * Examples: API endpoint not reachable, HTTP timeout, malformed response structure, + * missing mandatory fields in otherwise-parseable JSON, network error. + *

+ * These errors are typically transient and may be resolved by retry in a later + * batch run. The failure is recorded against the transient-error counter. + */ + TECHNICAL, + + /** + * A functional or content validation error occurred. + *

+ * Examples: invalid or generic title (e.g., "Dokument"), unparseable date string, + * AI response violates documented rules (e.g., title contains prohibited characters). + *

+ * These errors are deterministic and reflect issues with the AI-generated content + * itself or the document's content quality. The failure is recorded against the + * content-error counter, subject to the deterministic retry rule. + */ + FUNCTIONAL +} diff --git a/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/model/AiRawResponse.java b/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/model/AiRawResponse.java new file mode 100644 index 0000000..2c77611 --- /dev/null +++ b/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/model/AiRawResponse.java @@ -0,0 +1,45 @@ +package de.gecheckt.pdf.umbenenner.domain.model; + +import java.util.Objects; + +/** + * Unvalidated, uninterpreted raw response body from an AI service. + *

+ * This record holds the exact bytes or string returned by the AI HTTP endpoint, + * before any parsing, validation, or business-logic processing. It is used to: + *

+ *

+ * Persistance: The raw response is stored in SQLite history for + * traceability and future debugging. It may contain the full JSON structure or + * formatted text, depending on the AI service. + *

+ * Example: + *

+ * {@code
+ * AiRawResponse response = new AiRawResponse(
+ *     "{\"date\": \"2026-03-05\", \"title\": \"Stromabrechnung\", \"reasoning\": \"...\"}"
+ * );
+ * }
+ * 
+ * + * @param content the raw response body as a string (non-null, may be empty or malformed) + * + * @since M5 + */ +public record AiRawResponse(String content) { + + /** + * Compact constructor validating that content is not null. + * + * @throws NullPointerException if {@code content} is null + */ + public AiRawResponse { + Objects.requireNonNull(content, "content must not be null"); + } +} diff --git a/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/model/AiRequestRepresentation.java b/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/model/AiRequestRepresentation.java new file mode 100644 index 0000000..bcea8ed --- /dev/null +++ b/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/model/AiRequestRepresentation.java @@ -0,0 +1,73 @@ +package de.gecheckt.pdf.umbenenner.domain.model; + +import java.util.Objects; + +/** + * Deterministic, complete representation of the request sent to an AI service. + *

+ * This record captures the exact prompt, text, and configuration that were sent + * to the AI in a single request, allowing for reproducibility and debugging. + *

+ * Construction: The Application layer constructs this representation + * deterministically from: + *

+ *

+ * Field semantics: + *

+ *

+ * Persistence: Both prompt identifier and sent character count + * are recorded in the processing attempt history for traceability. + *

+ * Not included: + *

+ * + * @param promptIdentifier stable identifier for the prompt template; never null + * @param promptContent content of the prompt template; never null (may be empty, + * though typically meaningful) + * @param documentText extracted PDF text (already limited to max characters); + * never null (may be empty) + * @param sentCharacterCount exact number of characters from documentText that were + * sent to the AI; must be >= 0 and <= documentText.length() + * + * @since M5 + */ +public record AiRequestRepresentation( + PromptIdentifier promptIdentifier, + String promptContent, + String documentText, + int sentCharacterCount) { + + /** + * Compact constructor validating all fields. + * + * @throws NullPointerException if any field except possibly documentText is null + * @throws IllegalArgumentException if sentCharacterCount is out of valid range + */ + public AiRequestRepresentation { + Objects.requireNonNull(promptIdentifier, "promptIdentifier must not be null"); + Objects.requireNonNull(promptContent, "promptContent must not be null"); + Objects.requireNonNull(documentText, "documentText must not be null"); + if (sentCharacterCount < 0 || sentCharacterCount > documentText.length()) { + throw new IllegalArgumentException( + "sentCharacterCount must be >= 0 and <= documentText.length(); " + + "got " + sentCharacterCount + " but documentText.length() = " + documentText.length()); + } + } +} diff --git a/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/model/DateSource.java b/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/model/DateSource.java new file mode 100644 index 0000000..049fc8b --- /dev/null +++ b/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/model/DateSource.java @@ -0,0 +1,38 @@ +package de.gecheckt.pdf.umbenenner.domain.model; + +/** + * Enumeration of valid sources for a resolved document date. + *

+ * Each enum constant represents a specific origin or determination method for the date + * used in a naming proposal. The source is recorded for traceability. + *

+ * Semantics: + *

+ *

+ * The source is recorded in the processing attempt history for reproducibility + * and operational transparency. + * + * @since M5 + */ +public enum DateSource { + + /** + * The date was provided by the AI in its JSON response. + *

+ * The AI explicitly supplied a {@code date} field in valid {@code YYYY-MM-DD} format. + */ + AI_PROVIDED, + + /** + * The date is the current system date used as fallback. + *

+ * The AI either omitted the {@code date} field or provided no usable date. + * The application set the fallback to the current date from {@code ClockPort}. + */ + FALLBACK_CURRENT +} diff --git a/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/model/NamingProposal.java b/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/model/NamingProposal.java new file mode 100644 index 0000000..c63e9fd --- /dev/null +++ b/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/model/NamingProposal.java @@ -0,0 +1,68 @@ +package de.gecheckt.pdf.umbenenner.domain.model; + +import java.time.LocalDate; +import java.util.Objects; + +/** + * A validated naming proposal derived from AI analysis of a document. + *

+ * This record represents the core results of the AI-based naming stage: + * a proposed date, a proposed title, and the AI's reasoning. All three fields + * have been validated according to application rules at the time of creation. + *

+ * Field semantics: + *

+ *

+ * Not included in this proposal: + *

+ *

+ * Persistence: The naming proposal is persistently stored as part + * of the processing attempt history for reproducibility and audit. + * + * @param resolvedDate the effective date (never null); derived from AI or fallback + * @param dateSource origin of the date ({@link DateSource#AI_PROVIDED} or + * {@link DateSource#FALLBACK_CURRENT}); never null + * @param validatedTitle the title validated per application rules (non-null, non-empty, + * max 20 base characters as defined in requirements) + * @param aiReasoning the AI's explanation for the proposal (non-null, may be empty) + * + * @since M5 + */ +public record NamingProposal( + LocalDate resolvedDate, + DateSource dateSource, + String validatedTitle, + String aiReasoning) { + + /** + * Compact constructor validating all mandatory fields. + * + * @throws NullPointerException if any field is null + * @throws IllegalArgumentException if validatedTitle is empty + */ + public NamingProposal { + Objects.requireNonNull(resolvedDate, "resolvedDate must not be null"); + Objects.requireNonNull(dateSource, "dateSource must not be null"); + Objects.requireNonNull(validatedTitle, "validatedTitle must not be null"); + if (validatedTitle.isEmpty()) { + throw new IllegalArgumentException("validatedTitle must not be empty"); + } + Objects.requireNonNull(aiReasoning, "aiReasoning must not be null"); + } +} diff --git a/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/model/ProcessingStatus.java b/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/model/ProcessingStatus.java index c45eec4..1b6b128 100644 --- a/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/model/ProcessingStatus.java +++ b/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/model/ProcessingStatus.java @@ -9,7 +9,15 @@ package de.gecheckt.pdf.umbenenner.domain.model; *

* Overall-status semantics (master record): *