1
0

M5 komplett umgesetzt

This commit is contained in:
2026-04-07 12:26:14 +02:00
parent 506f5ac32e
commit 9874fdb1ba
51 changed files with 5960 additions and 536 deletions

View File

@@ -0,0 +1,53 @@
package de.gecheckt.pdf.umbenenner.domain.model;
import java.util.Objects;
/**
* Carries the AI-related traceability data produced during an AI naming attempt.
* <p>
* This record aggregates the metadata required to persist full AI traceability in
* the processing attempt history:
* <ul>
* <li>AI infrastructure details (model name, prompt identifier)</li>
* <li>Request size metrics (processed pages, sent character count)</li>
* <li>Raw AI output (for audit and diagnostics; stored in SQLite, not in log files)</li>
* </ul>
* <p>
* This context is produced whenever an AI call is attempted, regardless of whether
* the call succeeded or failed. Fields that could not be determined (e.g. raw response
* on connection failure) may be {@code null}.
*
* @param modelName the AI model name used in the request; never null
* @param promptIdentifier stable identifier of the prompt template; never null
* @param processedPageCount number of PDF pages included in the extraction; must be &gt;= 1
* @param sentCharacterCount number of document-text characters sent to the AI; must be &gt;= 0
* @param aiRawResponse the complete raw AI response body; {@code null} if the call did
* not return a response body (e.g. timeout or connection error)
*/
public record AiAttemptContext(
String modelName,
String promptIdentifier,
int processedPageCount,
int sentCharacterCount,
String aiRawResponse) {
/**
* Compact constructor validating mandatory fields.
*
* @throws NullPointerException if {@code modelName} or {@code promptIdentifier} is null
* @throws IllegalArgumentException if {@code processedPageCount} &lt; 1 or
* {@code sentCharacterCount} &lt; 0
*/
public AiAttemptContext {
Objects.requireNonNull(modelName, "modelName must not be null");
Objects.requireNonNull(promptIdentifier, "promptIdentifier must not be null");
if (processedPageCount < 1) {
throw new IllegalArgumentException(
"processedPageCount must be >= 1, but was: " + processedPageCount);
}
if (sentCharacterCount < 0) {
throw new IllegalArgumentException(
"sentCharacterCount must be >= 0, but was: " + sentCharacterCount);
}
}
}

View File

@@ -0,0 +1,40 @@
package de.gecheckt.pdf.umbenenner.domain.model;
import java.util.Objects;
/**
* Outcome indicating a deterministic functional (content) failure in the AI naming pipeline.
* <p>
* Functional failures occur when the AI returns a structurally valid response but the
* content violates the applicable fachliche rules, for example:
* <ul>
* <li>Title exceeds 20 characters</li>
* <li>Title contains prohibited special characters</li>
* <li>Title is a generic placeholder (e.g., "Dokument", "Scan")</li>
* <li>AI-provided date is present but not a valid YYYY-MM-DD string</li>
* </ul>
* <p>
* These failures are deterministic: retrying the same document against the same AI
* and prompt is unlikely to resolve the issue without a document or prompt change.
* The content error counter is incremented, and the standard one-retry rule applies.
*
* @param candidate the source document candidate; never null
* @param errorMessage human-readable description of the validation failure; never null
* @param aiContext AI traceability context for the attempt record; never null
*/
public record AiFunctionalFailure(
SourceDocumentCandidate candidate,
String errorMessage,
AiAttemptContext aiContext) implements DocumentProcessingOutcome {
/**
* Compact constructor validating mandatory fields.
*
* @throws NullPointerException if any field is null
*/
public AiFunctionalFailure {
Objects.requireNonNull(candidate, "candidate must not be null");
Objects.requireNonNull(errorMessage, "errorMessage must not be null");
Objects.requireNonNull(aiContext, "aiContext must not be null");
}
}

View File

@@ -0,0 +1,40 @@
package de.gecheckt.pdf.umbenenner.domain.model;
import java.util.Objects;
/**
* Outcome indicating a transient technical failure during the AI naming pipeline.
* <p>
* Technical failures include:
* <ul>
* <li>AI service not reachable</li>
* <li>HTTP timeout</li>
* <li>Connection error</li>
* <li>Unparseable or structurally invalid AI response (missing mandatory fields, invalid JSON)</li>
* </ul>
* <p>
* These failures are retryable. The transient error counter is incremented.
*
* @param candidate the source document candidate; never null
* @param errorMessage human-readable description of the failure; never null
* @param cause the underlying exception, or {@code null} if not applicable
* @param aiContext AI traceability context captured before or during the failure; never null
*/
public record AiTechnicalFailure(
SourceDocumentCandidate candidate,
String errorMessage,
Throwable cause,
AiAttemptContext aiContext) implements DocumentProcessingOutcome {
/**
* Compact constructor validating mandatory fields.
*
* @throws NullPointerException if {@code candidate}, {@code errorMessage}, or
* {@code aiContext} is null
*/
public AiTechnicalFailure {
Objects.requireNonNull(candidate, "candidate must not be null");
Objects.requireNonNull(errorMessage, "errorMessage must not be null");
Objects.requireNonNull(aiContext, "aiContext must not be null");
}
}

View File

@@ -26,6 +26,7 @@ package de.gecheckt.pdf.umbenenner.domain.model;
* </ul>
*/
public sealed interface DocumentProcessingOutcome
permits PreCheckPassed, PreCheckFailed, TechnicalDocumentError {
permits PreCheckPassed, PreCheckFailed, TechnicalDocumentError,
NamingProposalReady, AiTechnicalFailure, AiFunctionalFailure {
// Marker interface; concrete implementations define structure
}

View File

@@ -0,0 +1,39 @@
package de.gecheckt.pdf.umbenenner.domain.model;
import java.util.Objects;
/**
* Outcome indicating that an AI naming pipeline completed successfully and produced
* a validated naming proposal ready for persistence.
* <p>
* This outcome is returned when:
* <ol>
* <li>PDF text extraction and pre-checks passed.</li>
* <li>The AI was invoked and returned a parseable response.</li>
* <li>The response passed all semantic validation rules.</li>
* <li>A {@link NamingProposal} was produced.</li>
* </ol>
* <p>
* The document master record will be updated to {@link ProcessingStatus#PROPOSAL_READY};
* a physical target copy is not yet produced at this stage.
*
* @param candidate the source document candidate; never null
* @param proposal the validated naming proposal ready for persistence; never null
* @param aiContext AI traceability data required for the processing attempt record; never null
*/
public record NamingProposalReady(
SourceDocumentCandidate candidate,
NamingProposal proposal,
AiAttemptContext aiContext) implements DocumentProcessingOutcome {
/**
* Compact constructor validating all fields.
*
* @throws NullPointerException if any field is null
*/
public NamingProposalReady {
Objects.requireNonNull(candidate, "candidate must not be null");
Objects.requireNonNull(proposal, "proposal must not be null");
Objects.requireNonNull(aiContext, "aiContext must not be null");
}
}

View File

@@ -0,0 +1,201 @@
package de.gecheckt.pdf.umbenenner.domain.model;
import org.junit.jupiter.api.Test;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
/**
* Tests for AI-related domain types:
* {@link ParsedAiResponse}, {@link AiRequestRepresentation},
* {@link AiResponseParsingFailure}, {@link AiResponseParsingSuccess},
* and {@link AiErrorClassification}.
*/
class AiResponseTypesTest {
// -------------------------------------------------------------------------
// ParsedAiResponse
// -------------------------------------------------------------------------
@Test
void parsedAiResponse_withDate_fieldsAreSet() {
var parsed = new ParsedAiResponse("Rechnung", "AI reasoning", Optional.of("2026-01-15"));
assertEquals("Rechnung", parsed.title());
assertEquals("AI reasoning", parsed.reasoning());
assertTrue(parsed.dateString().isPresent());
assertEquals("2026-01-15", parsed.dateString().get());
}
@Test
void parsedAiResponse_withoutDate_dateStringIsEmpty() {
var parsed = new ParsedAiResponse("Rechnung", "AI reasoning", Optional.empty());
assertFalse(parsed.dateString().isPresent());
}
@Test
void parsedAiResponse_factoryMethod_withDate_wrapsDate() {
var parsed = ParsedAiResponse.of("Vertrag", "reasoning", "2026-03-01");
assertEquals("Vertrag", parsed.title());
assertTrue(parsed.dateString().isPresent());
assertEquals("2026-03-01", parsed.dateString().get());
}
@Test
void parsedAiResponse_factoryMethod_withNullDate_producesEmpty() {
var parsed = ParsedAiResponse.of("Vertrag", "reasoning", null);
assertFalse(parsed.dateString().isPresent());
}
@Test
void parsedAiResponse_nullTitle_throwsNPE() {
assertThrows(NullPointerException.class,
() -> new ParsedAiResponse(null, "reasoning", Optional.empty()));
}
@Test
void parsedAiResponse_nullReasoning_throwsNPE() {
assertThrows(NullPointerException.class,
() -> new ParsedAiResponse("title", null, Optional.empty()));
}
@Test
void parsedAiResponse_nullDateString_throwsNPE() {
assertThrows(NullPointerException.class,
() -> new ParsedAiResponse("title", "reasoning", null));
}
// -------------------------------------------------------------------------
// AiRequestRepresentation
// -------------------------------------------------------------------------
@Test
void aiRequestRepresentation_validConstruction_fieldsAreSet() {
var promptId = new PromptIdentifier("prompt-v1");
var repr = new AiRequestRepresentation(promptId, "Prompt text", "Document content", 16);
assertEquals(promptId, repr.promptIdentifier());
assertEquals("Prompt text", repr.promptContent());
assertEquals("Document content", repr.documentText());
assertEquals(16, repr.sentCharacterCount());
}
@Test
void aiRequestRepresentation_zeroSentCharacterCount_isValid() {
var promptId = new PromptIdentifier("p");
var repr = new AiRequestRepresentation(promptId, "prompt", "text", 0);
assertEquals(0, repr.sentCharacterCount());
}
@Test
void aiRequestRepresentation_sentCharCountEqualsTextLength_isValid() {
var promptId = new PromptIdentifier("p");
String text = "hello";
var repr = new AiRequestRepresentation(promptId, "prompt", text, text.length());
assertEquals(text.length(), repr.sentCharacterCount());
}
@Test
void aiRequestRepresentation_negativeSentCharCount_throwsIllegalArgument() {
var promptId = new PromptIdentifier("p");
assertThrows(IllegalArgumentException.class,
() -> new AiRequestRepresentation(promptId, "prompt", "text", -1));
}
@Test
void aiRequestRepresentation_sentCharCountExceedsTextLength_throwsIllegalArgument() {
var promptId = new PromptIdentifier("p");
assertThrows(IllegalArgumentException.class,
() -> new AiRequestRepresentation(promptId, "prompt", "short", 100));
}
@Test
void aiRequestRepresentation_nullPromptIdentifier_throwsNPE() {
assertThrows(NullPointerException.class,
() -> new AiRequestRepresentation(null, "prompt", "text", 4));
}
@Test
void aiRequestRepresentation_nullPromptContent_throwsNPE() {
var promptId = new PromptIdentifier("p");
assertThrows(NullPointerException.class,
() -> new AiRequestRepresentation(promptId, null, "text", 4));
}
@Test
void aiRequestRepresentation_nullDocumentText_throwsNPE() {
var promptId = new PromptIdentifier("p");
assertThrows(NullPointerException.class,
() -> new AiRequestRepresentation(promptId, "prompt", null, 0));
}
// -------------------------------------------------------------------------
// AiResponseParsingFailure
// -------------------------------------------------------------------------
@Test
void aiResponseParsingFailure_fieldsAreSet() {
var failure = new AiResponseParsingFailure("MISSING_TITLE", "AI response missing mandatory field 'title'");
assertEquals("MISSING_TITLE", failure.failureReason());
assertEquals("AI response missing mandatory field 'title'", failure.failureMessage());
assertInstanceOf(AiResponseParsingResult.class, failure);
}
@Test
void aiResponseParsingFailure_nullFailureReason_throwsNPE() {
assertThrows(NullPointerException.class,
() -> new AiResponseParsingFailure(null, "message"));
}
@Test
void aiResponseParsingFailure_nullFailureMessage_throwsNPE() {
assertThrows(NullPointerException.class,
() -> new AiResponseParsingFailure("REASON", null));
}
// -------------------------------------------------------------------------
// AiResponseParsingSuccess
// -------------------------------------------------------------------------
@Test
void aiResponseParsingSuccess_fieldsAreSet() {
var parsed = ParsedAiResponse.of("Rechnung", "reasoning", "2026-01-01");
var success = new AiResponseParsingSuccess(parsed);
assertEquals(parsed, success.response());
assertInstanceOf(AiResponseParsingResult.class, success);
}
@Test
void aiResponseParsingSuccess_nullResponse_throwsNPE() {
assertThrows(NullPointerException.class,
() -> new AiResponseParsingSuccess(null));
}
// -------------------------------------------------------------------------
// AiErrorClassification
// -------------------------------------------------------------------------
@Test
void aiErrorClassification_hasTwoValues() {
AiErrorClassification[] values = AiErrorClassification.values();
assertEquals(2, values.length);
}
@Test
void aiErrorClassification_technical_isEnumValue() {
assertEquals(AiErrorClassification.TECHNICAL,
AiErrorClassification.valueOf("TECHNICAL"));
}
@Test
void aiErrorClassification_functional_isEnumValue() {
assertEquals(AiErrorClassification.FUNCTIONAL,
AiErrorClassification.valueOf("FUNCTIONAL"));
}
}

View File

@@ -6,6 +6,7 @@ import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import static org.junit.jupiter.api.Assertions.*;
@@ -108,4 +109,166 @@ class DocumentProcessingOutcomeTest {
assertInstanceOf(DocumentProcessingOutcome.class, outcome);
}
}
// -------------------------------------------------------------------------
// AiAttemptContext
// -------------------------------------------------------------------------
@Test
void aiAttemptContext_validConstruction_fieldsAreSet() {
var ctx = new AiAttemptContext("gpt-4", "prompt-v1", 2, 500, "{\"title\":\"Test\"}");
assertEquals("gpt-4", ctx.modelName());
assertEquals("prompt-v1", ctx.promptIdentifier());
assertEquals(2, ctx.processedPageCount());
assertEquals(500, ctx.sentCharacterCount());
assertEquals("{\"title\":\"Test\"}", ctx.aiRawResponse());
}
@Test
void aiAttemptContext_nullRawResponse_isAllowed() {
var ctx = new AiAttemptContext("gpt-4", "prompt-v1", 1, 0, null);
assertNull(ctx.aiRawResponse());
}
@Test
void aiAttemptContext_nullModelName_throwsNPE() {
assertThrows(NullPointerException.class,
() -> new AiAttemptContext(null, "prompt-v1", 1, 0, null));
}
@Test
void aiAttemptContext_nullPromptIdentifier_throwsNPE() {
assertThrows(NullPointerException.class,
() -> new AiAttemptContext("gpt-4", null, 1, 0, null));
}
@Test
void aiAttemptContext_zeroPageCount_throwsIllegalArgument() {
assertThrows(IllegalArgumentException.class,
() -> new AiAttemptContext("gpt-4", "prompt-v1", 0, 0, null));
}
@Test
void aiAttemptContext_negativeCharCount_throwsIllegalArgument() {
assertThrows(IllegalArgumentException.class,
() -> new AiAttemptContext("gpt-4", "prompt-v1", 1, -1, null));
}
// -------------------------------------------------------------------------
// AiFunctionalFailure
// -------------------------------------------------------------------------
@Test
void aiFunctionalFailure_validConstruction_fieldsAreSet() {
var ctx = new AiAttemptContext("gpt-4", "prompt-v1", 1, 100, "{}");
var failure = new AiFunctionalFailure(candidate, "Title too long", ctx);
assertEquals(candidate, failure.candidate());
assertEquals("Title too long", failure.errorMessage());
assertEquals(ctx, failure.aiContext());
assertInstanceOf(DocumentProcessingOutcome.class, failure);
}
@Test
void aiFunctionalFailure_nullCandidate_throwsNPE() {
var ctx = new AiAttemptContext("gpt-4", "p", 1, 0, null);
assertThrows(NullPointerException.class,
() -> new AiFunctionalFailure(null, "error", ctx));
}
@Test
void aiFunctionalFailure_nullErrorMessage_throwsNPE() {
var ctx = new AiAttemptContext("gpt-4", "p", 1, 0, null);
assertThrows(NullPointerException.class,
() -> new AiFunctionalFailure(candidate, null, ctx));
}
@Test
void aiFunctionalFailure_nullAiContext_throwsNPE() {
assertThrows(NullPointerException.class,
() -> new AiFunctionalFailure(candidate, "error", null));
}
// -------------------------------------------------------------------------
// AiTechnicalFailure
// -------------------------------------------------------------------------
@Test
void aiTechnicalFailure_validConstruction_fieldsAreSet() {
var ctx = new AiAttemptContext("gpt-4", "prompt-v1", 1, 100, null);
var cause = new RuntimeException("timeout");
var failure = new AiTechnicalFailure(candidate, "HTTP timeout", cause, ctx);
assertEquals(candidate, failure.candidate());
assertEquals("HTTP timeout", failure.errorMessage());
assertEquals(cause, failure.cause());
assertEquals(ctx, failure.aiContext());
assertInstanceOf(DocumentProcessingOutcome.class, failure);
}
@Test
void aiTechnicalFailure_nullCause_isAllowed() {
var ctx = new AiAttemptContext("gpt-4", "p", 1, 0, null);
var failure = new AiTechnicalFailure(candidate, "error", null, ctx);
assertNull(failure.cause());
}
@Test
void aiTechnicalFailure_nullCandidate_throwsNPE() {
var ctx = new AiAttemptContext("gpt-4", "p", 1, 0, null);
assertThrows(NullPointerException.class,
() -> new AiTechnicalFailure(null, "error", null, ctx));
}
@Test
void aiTechnicalFailure_nullErrorMessage_throwsNPE() {
var ctx = new AiAttemptContext("gpt-4", "p", 1, 0, null);
assertThrows(NullPointerException.class,
() -> new AiTechnicalFailure(candidate, null, null, ctx));
}
@Test
void aiTechnicalFailure_nullAiContext_throwsNPE() {
assertThrows(NullPointerException.class,
() -> new AiTechnicalFailure(candidate, "error", null, null));
}
// -------------------------------------------------------------------------
// NamingProposalReady
// -------------------------------------------------------------------------
@Test
void namingProposalReady_validConstruction_fieldsAreSet() {
var ctx = new AiAttemptContext("gpt-4", "prompt-v1", 1, 100, "{\"title\":\"Rechnung\"}");
var proposal = new NamingProposal(LocalDate.of(2026, 1, 15), DateSource.AI_PROVIDED, "Rechnung", "AI reasoning");
var ready = new NamingProposalReady(candidate, proposal, ctx);
assertEquals(candidate, ready.candidate());
assertEquals(proposal, ready.proposal());
assertEquals(ctx, ready.aiContext());
assertInstanceOf(DocumentProcessingOutcome.class, ready);
}
@Test
void namingProposalReady_nullCandidate_throwsNPE() {
var ctx = new AiAttemptContext("gpt-4", "p", 1, 0, null);
var proposal = new NamingProposal(LocalDate.now(), DateSource.AI_PROVIDED, "Test", "r");
assertThrows(NullPointerException.class,
() -> new NamingProposalReady(null, proposal, ctx));
}
@Test
void namingProposalReady_nullProposal_throwsNPE() {
var ctx = new AiAttemptContext("gpt-4", "p", 1, 0, null);
assertThrows(NullPointerException.class,
() -> new NamingProposalReady(candidate, null, ctx));
}
@Test
void namingProposalReady_nullAiContext_throwsNPE() {
var proposal = new NamingProposal(LocalDate.now(), DateSource.AI_PROVIDED, "Test", "r");
assertThrows(NullPointerException.class,
() -> new NamingProposalReady(candidate, proposal, null));
}
}