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

@@ -4,6 +4,7 @@ import de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.ArrayList; import java.util.ArrayList;
@@ -16,8 +17,20 @@ import java.util.List;
* and basic path existence checks. Throws {@link InvalidStartConfigurationException} * and basic path existence checks. Throws {@link InvalidStartConfigurationException}
* if any validation rule fails. * if any validation rule fails.
* <p> * <p>
* Supports injected source folder validation for testability * Supports injected source and target folder validation for testability
* (allows mocking of platform-dependent filesystem checks). * (allows mocking of platform-dependent filesystem checks).
*
* <h2>Target folder validation</h2>
* <p>
* The target folder is validated as "present or technically creatable":
* <ul>
* <li>If it already exists: must be a directory and writable.</li>
* <li>If it does not yet exist: the {@link TargetFolderChecker} attempts to create it
* via {@code Files.createDirectories}. Creation failure is a hard validation error.</li>
* </ul>
* <p>
* This behaviour ensures the target write path is technically usable before any
* document processing begins, without requiring the operator to create the folder manually.
*/ */
public class StartConfigurationValidator { public class StartConfigurationValidator {
@@ -48,22 +61,64 @@ public class StartConfigurationValidator {
String checkSourceFolder(Path path); String checkSourceFolder(Path path);
} }
/**
* Abstraction for target folder existence, creatability, and write-access checks.
* <p>
* Separates filesystem operations from validation logic to enable
* platform-independent unit testing (mocking) of write-access and creation edge cases.
* <p>
* The default implementation attempts to create the folder via
* {@code Files.createDirectories} if it does not yet exist, then verifies it is a
* directory and writable. Tests can substitute alternative implementations.
*/
@FunctionalInterface
public interface TargetFolderChecker {
/**
* Checks target folder usability and returns a validation error message, or null if valid.
* <p>
* Checks (in order):
* <ol>
* <li>If folder does not exist: attempt to create it via {@code createDirectories}.</li>
* <li>Is a directory.</li>
* <li>Is writable (required for the file-copy write path).</li>
* </ol>
*
* @param path the target folder path
* @return error message string, or null if all checks pass
*/
String checkTargetFolder(Path path);
}
private final SourceFolderChecker sourceFolderChecker; private final SourceFolderChecker sourceFolderChecker;
private final TargetFolderChecker targetFolderChecker;
/** /**
* Creates a validator with the default source folder checker (NIO-based). * Creates a validator with default NIO-based source and target folder checkers.
*/ */
public StartConfigurationValidator() { public StartConfigurationValidator() {
this(new DefaultSourceFolderChecker()); this(new DefaultSourceFolderChecker(), new DefaultTargetFolderChecker());
} }
/** /**
* Creates a validator with a custom source folder checker (primarily for testing). * Creates a validator with a custom source folder checker (primarily for testing).
* Uses the default NIO-based target folder checker.
* *
* @param sourceFolderChecker the checker to use (must not be null) * @param sourceFolderChecker the source folder checker to use (must not be null)
*/ */
public StartConfigurationValidator(SourceFolderChecker sourceFolderChecker) { public StartConfigurationValidator(SourceFolderChecker sourceFolderChecker) {
this(sourceFolderChecker, new DefaultTargetFolderChecker());
}
/**
* Creates a validator with custom source and target folder checkers (primarily for testing).
*
* @param sourceFolderChecker the source folder checker to use (must not be null)
* @param targetFolderChecker the target folder checker to use (must not be null)
*/
public StartConfigurationValidator(SourceFolderChecker sourceFolderChecker,
TargetFolderChecker targetFolderChecker) {
this.sourceFolderChecker = sourceFolderChecker; this.sourceFolderChecker = sourceFolderChecker;
this.targetFolderChecker = targetFolderChecker;
} }
/** /**
@@ -130,7 +185,14 @@ public class StartConfigurationValidator {
} }
private void validateTargetFolder(Path targetFolder, List<String> errors) { private void validateTargetFolder(Path targetFolder, List<String> errors) {
validateRequiredExistingDirectory(targetFolder, "target.folder", errors); if (targetFolder == null) {
errors.add("- target.folder: must not be null");
return;
}
String checkError = targetFolderChecker.checkTargetFolder(targetFolder);
if (checkError != null) {
errors.add(checkError);
}
} }
private void validateSqliteFile(Path sqliteFile, List<String> errors) { private void validateSqliteFile(Path sqliteFile, List<String> errors) {
@@ -321,4 +383,38 @@ public class StartConfigurationValidator {
return null; // All checks passed return null; // All checks passed
} }
} }
/**
* Default NIO-based implementation of {@link TargetFolderChecker}.
* <p>
* Validates that the target folder is present and writable for the file-copy write path.
* If the folder does not yet exist, creation is attempted via {@code Files.createDirectories}.
* <p>
* This satisfies the "present or technically creatable" requirement: the folder need not
* exist before the application starts, but must be reachable at startup time.
* <p>
* This separation allows unit tests to inject alternative implementations
* that control the outcome of write-access or creation checks without relying on actual
* filesystem permissions (which are platform-dependent).
*/
private static class DefaultTargetFolderChecker implements TargetFolderChecker {
@Override
public String checkTargetFolder(Path path) {
if (!Files.exists(path)) {
try {
Files.createDirectories(path);
} catch (IOException e) {
return "- target.folder: path does not exist and could not be created: "
+ path + " (" + e.getMessage() + ")";
}
}
if (!Files.isDirectory(path)) {
return "- target.folder: path is not a directory: " + path;
}
if (!Files.isWritable(path)) {
return "- target.folder: directory is not writable: " + path;
}
return null; // All checks passed
}
}
} }

View File

@@ -0,0 +1,24 @@
package de.gecheckt.pdf.umbenenner.adapter.out.clock;
import java.time.Instant;
import de.gecheckt.pdf.umbenenner.application.port.out.ClockPort;
/**
* System clock implementation of {@link ClockPort}.
* <p>
* Returns the current wall-clock time from the JVM system clock.
* Intended for production use; tests should inject a controlled clock implementation.
*/
public class SystemClockAdapter implements ClockPort {
/**
* Returns the current system time as an {@link Instant}.
*
* @return the current UTC instant; never null
*/
@Override
public Instant now() {
return Instant.now();
}
}

View File

@@ -85,7 +85,9 @@ public class SqliteDocumentRecordRepositoryAdapter implements DocumentRecordRepo
last_failure_instant, last_failure_instant,
last_success_instant, last_success_instant,
created_at, created_at,
updated_at updated_at,
last_target_path,
last_target_file_name
FROM document_record FROM document_record
WHERE fingerprint = ? WHERE fingerprint = ?
"""; """;
@@ -146,8 +148,10 @@ public class SqliteDocumentRecordRepositoryAdapter implements DocumentRecordRepo
last_failure_instant, last_failure_instant,
last_success_instant, last_success_instant,
created_at, created_at,
updated_at updated_at,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) last_target_path,
last_target_file_name
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"""; """;
try (Connection connection = getConnection(); try (Connection connection = getConnection();
@@ -163,6 +167,8 @@ public class SqliteDocumentRecordRepositoryAdapter implements DocumentRecordRepo
statement.setString(8, instantToString(record.lastSuccessInstant())); statement.setString(8, instantToString(record.lastSuccessInstant()));
statement.setString(9, instantToString(record.createdAt())); statement.setString(9, instantToString(record.createdAt()));
statement.setString(10, instantToString(record.updatedAt())); statement.setString(10, instantToString(record.updatedAt()));
statement.setString(11, record.lastTargetPath());
statement.setString(12, record.lastTargetFileName());
int rowsAffected = statement.executeUpdate(); int rowsAffected = statement.executeUpdate();
if (rowsAffected != 1) { if (rowsAffected != 1) {
@@ -205,7 +211,9 @@ public class SqliteDocumentRecordRepositoryAdapter implements DocumentRecordRepo
transient_error_count = ?, transient_error_count = ?,
last_failure_instant = ?, last_failure_instant = ?,
last_success_instant = ?, last_success_instant = ?,
updated_at = ? updated_at = ?,
last_target_path = ?,
last_target_file_name = ?
WHERE fingerprint = ? WHERE fingerprint = ?
"""; """;
@@ -220,7 +228,9 @@ public class SqliteDocumentRecordRepositoryAdapter implements DocumentRecordRepo
statement.setString(6, instantToString(record.lastFailureInstant())); statement.setString(6, instantToString(record.lastFailureInstant()));
statement.setString(7, instantToString(record.lastSuccessInstant())); statement.setString(7, instantToString(record.lastSuccessInstant()));
statement.setString(8, instantToString(record.updatedAt())); statement.setString(8, instantToString(record.updatedAt()));
statement.setString(9, record.fingerprint().sha256Hex()); statement.setString(9, record.lastTargetPath());
statement.setString(10, record.lastTargetFileName());
statement.setString(11, record.fingerprint().sha256Hex());
int rowsAffected = statement.executeUpdate(); int rowsAffected = statement.executeUpdate();
if (rowsAffected != 1) { if (rowsAffected != 1) {
@@ -260,7 +270,9 @@ public class SqliteDocumentRecordRepositoryAdapter implements DocumentRecordRepo
stringToInstant(rs.getString("last_failure_instant")), stringToInstant(rs.getString("last_failure_instant")),
stringToInstant(rs.getString("last_success_instant")), stringToInstant(rs.getString("last_success_instant")),
stringToInstant(rs.getString("created_at")), stringToInstant(rs.getString("created_at")),
stringToInstant(rs.getString("updated_at")) stringToInstant(rs.getString("updated_at")),
rs.getString("last_target_path"),
rs.getString("last_target_file_name")
); );
} }

View File

@@ -6,7 +6,9 @@ import java.sql.PreparedStatement;
import java.sql.ResultSet; import java.sql.ResultSet;
import java.sql.SQLException; import java.sql.SQLException;
import java.sql.Statement; import java.sql.Statement;
import java.sql.Types;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
@@ -17,13 +19,21 @@ import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException; import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt; import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttemptRepository; import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttemptRepository;
import de.gecheckt.pdf.umbenenner.domain.model.DateSource;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint; import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
/** /**
* SQLite implementation of {@link ProcessingAttemptRepository}. * SQLite implementation of {@link ProcessingAttemptRepository}.
* <p> * <p>
* Provides CRUD operations for the processing attempt history (Versuchshistorie) * Provides CRUD operations for the processing attempt history (Versuchshistorie)
* with explicit mapping between application types and the SQLite schema. * including all AI traceability fields added during schema evolution.
* <p>
* <strong>Schema compatibility:</strong> This adapter writes all columns including
* the AI traceability columns. When reading rows that were written before schema
* evolution, those columns contain {@code NULL} and are mapped to {@code null}
* in the Java record.
* <p> * <p>
* <strong>Architecture boundary:</strong> All JDBC and SQLite details are strictly * <strong>Architecture boundary:</strong> All JDBC and SQLite details are strictly
* confined to this class. No JDBC types appear in the port interface or in any * confined to this class. No JDBC types appear in the port interface or in any
@@ -65,9 +75,7 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
*/ */
@Override @Override
public int loadNextAttemptNumber(DocumentFingerprint fingerprint) { public int loadNextAttemptNumber(DocumentFingerprint fingerprint) {
if (fingerprint == null) { Objects.requireNonNull(fingerprint, "fingerprint must not be null");
throw new NullPointerException("fingerprint must not be null");
}
String sql = """ String sql = """
SELECT COALESCE(MAX(attempt_number), 0) + 1 AS next_attempt_number SELECT COALESCE(MAX(attempt_number), 0) + 1 AS next_attempt_number
@@ -78,7 +86,6 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
try (Connection connection = getConnection(); try (Connection connection = getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) { PreparedStatement statement = connection.prepareStatement(sql)) {
// Enable foreign key enforcement for this connection
try (Statement pragmaStmt = connection.createStatement()) { try (Statement pragmaStmt = connection.createStatement()) {
pragmaStmt.execute(PRAGMA_FOREIGN_KEYS_ON); pragmaStmt.execute(PRAGMA_FOREIGN_KEYS_ON);
} }
@@ -89,34 +96,27 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
if (rs.next()) { if (rs.next()) {
return rs.getInt("next_attempt_number"); return rs.getInt("next_attempt_number");
} else { } else {
// This should not happen, but fallback to 1
return 1; return 1;
} }
} }
} catch (SQLException e) { } catch (SQLException e) {
String message = "Failed to load next attempt number for fingerprint '" + String message = "Failed to load next attempt number for fingerprint '"
fingerprint.sha256Hex() + "': " + e.getMessage(); + fingerprint.sha256Hex() + "': " + e.getMessage();
logger.error(message, e); logger.error(message, e);
throw new DocumentPersistenceException(message, e); throw new DocumentPersistenceException(message, e);
} }
} }
/** /**
* Persists exactly one processing attempt record. * Persists exactly one processing attempt record including all AI traceability fields.
* <p>
* The {@link ProcessingAttempt#attemptNumber()} must have been obtained from
* {@link #loadNextAttemptNumber(DocumentFingerprint)} in the same run to guarantee
* monotonic ordering.
* *
* @param attempt the attempt to persist; must not be null * @param attempt the attempt to persist; must not be null
* @throws DocumentPersistenceException if the insert fails due to a technical error * @throws DocumentPersistenceException if the insert fails due to a technical error
*/ */
@Override @Override
public void save(ProcessingAttempt attempt) { public void save(ProcessingAttempt attempt) {
if (attempt == null) { Objects.requireNonNull(attempt, "attempt must not be null");
throw new NullPointerException("attempt must not be null");
}
String sql = """ String sql = """
INSERT INTO processing_attempt ( INSERT INTO processing_attempt (
@@ -128,15 +128,24 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
status, status,
failure_class, failure_class,
failure_message, failure_message,
retryable retryable,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) model_name,
prompt_identifier,
processed_page_count,
sent_character_count,
ai_raw_response,
ai_reasoning,
resolved_date,
date_source,
validated_title,
final_target_file_name
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"""; """;
try (Connection connection = getConnection(); try (Connection connection = getConnection();
Statement pragmaStmt = connection.createStatement(); Statement pragmaStmt = connection.createStatement();
PreparedStatement statement = connection.prepareStatement(sql)) { PreparedStatement statement = connection.prepareStatement(sql)) {
// Enable foreign key enforcement for this connection
pragmaStmt.execute(PRAGMA_FOREIGN_KEYS_ON); pragmaStmt.execute(PRAGMA_FOREIGN_KEYS_ON);
statement.setString(1, attempt.fingerprint().sha256Hex()); statement.setString(1, attempt.fingerprint().sha256Hex());
@@ -145,11 +154,22 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
statement.setString(4, attempt.startedAt().toString()); statement.setString(4, attempt.startedAt().toString());
statement.setString(5, attempt.endedAt().toString()); statement.setString(5, attempt.endedAt().toString());
statement.setString(6, attempt.status().name()); statement.setString(6, attempt.status().name());
setNullableString(statement, 7, attempt.failureClass());
// Handle nullable fields setNullableString(statement, 8, attempt.failureMessage());
statement.setString(7, attempt.failureClass());
statement.setString(8, attempt.failureMessage());
statement.setBoolean(9, attempt.retryable()); statement.setBoolean(9, attempt.retryable());
// AI traceability fields
setNullableString(statement, 10, attempt.modelName());
setNullableString(statement, 11, attempt.promptIdentifier());
setNullableInteger(statement, 12, attempt.processedPageCount());
setNullableInteger(statement, 13, attempt.sentCharacterCount());
setNullableString(statement, 14, attempt.aiRawResponse());
setNullableString(statement, 15, attempt.aiReasoning());
setNullableString(statement, 16,
attempt.resolvedDate() != null ? attempt.resolvedDate().toString() : null);
setNullableString(statement, 17,
attempt.dateSource() != null ? attempt.dateSource().name() : null);
setNullableString(statement, 18, attempt.validatedTitle());
setNullableString(statement, 19, attempt.finalTargetFileName());
int rowsAffected = statement.executeUpdate(); int rowsAffected = statement.executeUpdate();
if (rowsAffected != 1) { if (rowsAffected != 1) {
@@ -161,8 +181,8 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
attempt.attemptNumber(), attempt.fingerprint().sha256Hex()); attempt.attemptNumber(), attempt.fingerprint().sha256Hex());
} catch (SQLException e) { } catch (SQLException e) {
String message = "Failed to save processing attempt #" + attempt.attemptNumber() + String message = "Failed to save processing attempt #" + attempt.attemptNumber()
" for fingerprint '" + attempt.fingerprint().sha256Hex() + "': " + e.getMessage(); + " for fingerprint '" + attempt.fingerprint().sha256Hex() + "': " + e.getMessage();
logger.error(message, e); logger.error(message, e);
throw new DocumentPersistenceException(message, e); throw new DocumentPersistenceException(message, e);
} }
@@ -171,31 +191,22 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
/** /**
* Returns all historised attempts for the given fingerprint, ordered by * Returns all historised attempts for the given fingerprint, ordered by
* {@link ProcessingAttempt#attemptNumber()} ascending. * {@link ProcessingAttempt#attemptNumber()} ascending.
* <p>
* Returns an empty list if no attempts have been recorded yet.
* Intended for use in tests and diagnostics; not required on the primary batch path.
* *
* @param fingerprint the document identity; must not be null * @param fingerprint the document identity; must not be null
* @return immutable list of attempts, ordered by attempt number; never null * @return immutable list of attempts; never null
* @throws DocumentPersistenceException if the query fails due to a technical error * @throws DocumentPersistenceException if the query fails
*/ */
@Override @Override
public List<ProcessingAttempt> findAllByFingerprint(DocumentFingerprint fingerprint) { public List<ProcessingAttempt> findAllByFingerprint(DocumentFingerprint fingerprint) {
if (fingerprint == null) { Objects.requireNonNull(fingerprint, "fingerprint must not be null");
throw new NullPointerException("fingerprint must not be null");
}
String sql = """ String sql = """
SELECT SELECT
fingerprint, fingerprint, run_id, attempt_number, started_at, ended_at,
run_id, status, failure_class, failure_message, retryable,
attempt_number, model_name, prompt_identifier, processed_page_count, sent_character_count,
started_at, ai_raw_response, ai_reasoning, resolved_date, date_source, validated_title,
ended_at, final_target_file_name
status,
failure_class,
failure_message,
retryable
FROM processing_attempt FROM processing_attempt
WHERE fingerprint = ? WHERE fingerprint = ?
ORDER BY attempt_number ASC ORDER BY attempt_number ASC
@@ -205,53 +216,142 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
Statement pragmaStmt = connection.createStatement(); Statement pragmaStmt = connection.createStatement();
PreparedStatement statement = connection.prepareStatement(sql)) { PreparedStatement statement = connection.prepareStatement(sql)) {
// Enable foreign key enforcement for this connection
pragmaStmt.execute(PRAGMA_FOREIGN_KEYS_ON); pragmaStmt.execute(PRAGMA_FOREIGN_KEYS_ON);
statement.setString(1, fingerprint.sha256Hex()); statement.setString(1, fingerprint.sha256Hex());
try (ResultSet rs = statement.executeQuery()) { try (ResultSet rs = statement.executeQuery()) {
List<ProcessingAttempt> attempts = new ArrayList<>(); List<ProcessingAttempt> attempts = new ArrayList<>();
while (rs.next()) { while (rs.next()) {
ProcessingAttempt attempt = mapResultSetToProcessingAttempt(rs); attempts.add(mapResultSetToProcessingAttempt(rs));
attempts.add(attempt);
} }
return List.copyOf(attempts); // Return immutable copy return List.copyOf(attempts);
} }
} catch (SQLException e) { } catch (SQLException e) {
String message = "Failed to find processing attempts for fingerprint '" + String message = "Failed to find processing attempts for fingerprint '"
fingerprint.sha256Hex() + "': " + e.getMessage(); + fingerprint.sha256Hex() + "': " + e.getMessage();
logger.error(message, e); logger.error(message, e);
throw new DocumentPersistenceException(message, e); throw new DocumentPersistenceException(message, e);
} }
} }
/** /**
* Maps a ResultSet row to a ProcessingAttempt. * Returns the most recent attempt with status {@code PROPOSAL_READY} for the given
* fingerprint, or {@code null} if no such attempt exists.
* <p>
* This is the <em>leading source</em> for the naming proposal: the most recent
* {@code PROPOSAL_READY} attempt carries the validated date, title, and reasoning
* that subsequent processing steps consume.
* *
* @param rs the ResultSet positioned at the current row * @param fingerprint the document identity; must not be null
* @return the mapped ProcessingAttempt * @return the most recent {@code PROPOSAL_READY} attempt, or {@code null}
* @throws SQLException if reading from the ResultSet fails * @throws DocumentPersistenceException if the query fails
*/ */
public ProcessingAttempt findLatestProposalReadyAttempt(DocumentFingerprint fingerprint) {
Objects.requireNonNull(fingerprint, "fingerprint must not be null");
String sql = """
SELECT
fingerprint, run_id, attempt_number, started_at, ended_at,
status, failure_class, failure_message, retryable,
model_name, prompt_identifier, processed_page_count, sent_character_count,
ai_raw_response, ai_reasoning, resolved_date, date_source, validated_title,
final_target_file_name
FROM processing_attempt
WHERE fingerprint = ?
AND status = 'PROPOSAL_READY'
ORDER BY attempt_number DESC
LIMIT 1
""";
try (Connection connection = getConnection();
Statement pragmaStmt = connection.createStatement();
PreparedStatement statement = connection.prepareStatement(sql)) {
pragmaStmt.execute(PRAGMA_FOREIGN_KEYS_ON);
statement.setString(1, fingerprint.sha256Hex());
try (ResultSet rs = statement.executeQuery()) {
if (rs.next()) {
return mapResultSetToProcessingAttempt(rs);
}
return null;
}
} catch (SQLException e) {
String message = "Failed to find latest PROPOSAL_READY attempt for fingerprint '"
+ fingerprint.sha256Hex() + "': " + e.getMessage();
logger.error(message, e);
throw new DocumentPersistenceException(message, e);
}
}
// -------------------------------------------------------------------------
// Mapping helpers
// -------------------------------------------------------------------------
private ProcessingAttempt mapResultSetToProcessingAttempt(ResultSet rs) throws SQLException { private ProcessingAttempt mapResultSetToProcessingAttempt(ResultSet rs) throws SQLException {
String resolvedDateStr = rs.getString("resolved_date");
LocalDate resolvedDate = resolvedDateStr != null ? LocalDate.parse(resolvedDateStr) : null;
String dateSourceStr = rs.getString("date_source");
DateSource dateSource = dateSourceStr != null ? DateSource.valueOf(dateSourceStr) : null;
Integer processedPageCount = (Integer) getNullableInt(rs, "processed_page_count");
Integer sentCharacterCount = (Integer) getNullableInt(rs, "sent_character_count");
return new ProcessingAttempt( return new ProcessingAttempt(
new DocumentFingerprint(rs.getString("fingerprint")), new DocumentFingerprint(rs.getString("fingerprint")),
new de.gecheckt.pdf.umbenenner.domain.model.RunId(rs.getString("run_id")), new RunId(rs.getString("run_id")),
rs.getInt("attempt_number"), rs.getInt("attempt_number"),
Instant.parse(rs.getString("started_at")), Instant.parse(rs.getString("started_at")),
Instant.parse(rs.getString("ended_at")), Instant.parse(rs.getString("ended_at")),
de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus.valueOf(rs.getString("status")), ProcessingStatus.valueOf(rs.getString("status")),
rs.getString("failure_class"), rs.getString("failure_class"),
rs.getString("failure_message"), rs.getString("failure_message"),
rs.getBoolean("retryable") rs.getBoolean("retryable"),
rs.getString("model_name"),
rs.getString("prompt_identifier"),
processedPageCount,
sentCharacterCount,
rs.getString("ai_raw_response"),
rs.getString("ai_reasoning"),
resolvedDate,
dateSource,
rs.getString("validated_title"),
rs.getString("final_target_file_name")
); );
} }
// -------------------------------------------------------------------------
// JDBC nullable helpers
// -------------------------------------------------------------------------
private static void setNullableString(PreparedStatement stmt, int index, String value)
throws SQLException {
if (value == null) {
stmt.setNull(index, Types.VARCHAR);
} else {
stmt.setString(index, value);
}
}
private static void setNullableInteger(PreparedStatement stmt, int index, Integer value)
throws SQLException {
if (value == null) {
stmt.setNull(index, Types.INTEGER);
} else {
stmt.setInt(index, value);
}
}
private static Object getNullableInt(ResultSet rs, String column) throws SQLException {
int value = rs.getInt(column);
return rs.wasNull() ? null : value;
}
/** /**
* Returns the JDBC URL this adapter uses to connect to the SQLite database. * Returns the JDBC URL this adapter uses.
* <p>
* Intended for logging and diagnostics only.
* *
* @return the JDBC URL; never null or blank * @return the JDBC URL; never null or blank
*/ */
@@ -260,12 +360,7 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
} }
/** /**
* Gets a connection to the database. * Returns a JDBC connection. May be overridden in tests to provide shared connections.
* <p>
* This method can be overridden by subclasses to provide a shared connection.
*
* @return a new database connection
* @throws SQLException if the connection cannot be established
*/ */
protected Connection getConnection() throws SQLException { protected Connection getConnection() throws SQLException {
return DriverManager.getConnection(jdbcUrl); return DriverManager.getConnection(jdbcUrl);

View File

@@ -2,6 +2,7 @@ package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
import java.sql.Connection; import java.sql.Connection;
import java.sql.DriverManager; import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException; import java.sql.SQLException;
import java.sql.Statement; import java.sql.Statement;
import java.util.Objects; import java.util.Objects;
@@ -16,9 +17,8 @@ import de.gecheckt.pdf.umbenenner.application.port.out.PersistenceSchemaInitiali
* SQLite implementation of {@link PersistenceSchemaInitializationPort}. * SQLite implementation of {@link PersistenceSchemaInitializationPort}.
* <p> * <p>
* Creates or verifies the two-level persistence schema in the configured SQLite * Creates or verifies the two-level persistence schema in the configured SQLite
* database file. All DDL uses {@code IF NOT EXISTS} semantics, making the operation * database file, and performs a controlled schema evolution from an earlier schema
* fully idempotent: calling {@link #initializeSchema()} on an already-initialised * version to the current one.
* database succeeds without error and without modifying existing data.
* *
* <h2>Two-level schema</h2> * <h2>Two-level schema</h2>
* <p>The schema consists of exactly two tables: * <p>The schema consists of exactly two tables:
@@ -30,10 +30,29 @@ import de.gecheckt.pdf.umbenenner.application.port.out.PersistenceSchemaInitiali
* the master record via fingerprint.</li> * the master record via fingerprint.</li>
* </ol> * </ol>
* *
* <h2>Schema evolution</h2>
* <p>
* When upgrading from an earlier schema, this adapter uses idempotent
* {@code ALTER TABLE ... ADD COLUMN} statements for both tables. Columns that already
* exist are silently skipped, making the evolution safe to run on both fresh and existing
* databases. The current evolution adds:
* <ul>
* <li>AI-traceability columns to {@code processing_attempt}</li>
* <li>Target-copy columns ({@code last_target_path}, {@code last_target_file_name}) to
* {@code document_record}</li>
* <li>Target-copy column ({@code final_target_file_name}) to {@code processing_attempt}</li>
* </ul>
*
* <h2>M4→current-schema status migration</h2>
* <p>
* Documents in an earlier positive intermediate state ({@code SUCCESS} recorded without
* a validated naming proposal) are idempotently migrated to {@code READY_FOR_AI} so that
* the AI naming pipeline processes them in the next run. Terminal negative states
* ({@code FAILED_RETRYABLE}, {@code FAILED_FINAL}, skip states) are left unchanged.
*
* <h2>Initialisation timing</h2> * <h2>Initialisation timing</h2>
* <p>This adapter must be invoked <em>once</em> at program startup, before the batch * <p>This adapter must be invoked <em>once</em> at program startup, before the batch
* document processing loop begins. It is wired by the bootstrap module and called * document processing loop begins.
* explicitly through the port. There is no lazy or deferred initialisation.
* *
* <h2>Architecture boundary</h2> * <h2>Architecture boundary</h2>
* <p>All JDBC connections, SQL DDL, and SQLite-specific behaviour are strictly confined * <p>All JDBC connections, SQL DDL, and SQLite-specific behaviour are strictly confined
@@ -44,34 +63,17 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti
private static final Logger logger = LogManager.getLogger(SqliteSchemaInitializationAdapter.class); private static final Logger logger = LogManager.getLogger(SqliteSchemaInitializationAdapter.class);
// -------------------------------------------------------------------------
// DDL — document_record table
// -------------------------------------------------------------------------
/** /**
* DDL for the document master record table. * DDL for the document master record table.
* <p> * <p>
* <strong>Columns (mandatory fields):</strong> * Columns: id (PK), fingerprint (unique), last_known_source_locator,
* <ul> * last_known_source_file_name, overall_status, content_error_count,
* <li>{@code id} — internal surrogate primary key (auto-increment).</li> * transient_error_count, last_failure_instant, last_success_instant,
* <li>{@code fingerprint} — SHA-256 hex string; unique natural key; never null.</li> * created_at, updated_at.
* <li>{@code last_known_source_locator} — opaque locator value (file path string);
* never null.</li>
* <li>{@code last_known_source_file_name} — human-readable file name for logging;
* never null.</li>
* <li>{@code overall_status} — current processing status as enum name string;
* never null.</li>
* <li>{@code content_error_count} — count of deterministic content errors;
* default 0; never negative.</li>
* <li>{@code transient_error_count} — count of transient technical errors;
* default 0; never negative.</li>
* <li>{@code last_failure_instant} — ISO-8601 UTC timestamp of the most recent
* failure; nullable.</li>
* <li>{@code last_success_instant} — ISO-8601 UTC timestamp of the successful
* processing; nullable.</li>
* <li>{@code created_at} — ISO-8601 UTC timestamp of record creation; never null.</li>
* <li>{@code updated_at} — ISO-8601 UTC timestamp of the most recent update;
* never null.</li>
* </ul>
* <p>
* <strong>Not included (M5+ fields):</strong> target path, target file name,
* AI-related fields.
*/ */
private static final String DDL_CREATE_DOCUMENT_RECORD = """ private static final String DDL_CREATE_DOCUMENT_RECORD = """
CREATE TABLE IF NOT EXISTS document_record ( CREATE TABLE IF NOT EXISTS document_record (
@@ -90,36 +92,18 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti
) )
"""; """;
// -------------------------------------------------------------------------
// DDL — processing_attempt table (base schema, without AI traceability cols)
// -------------------------------------------------------------------------
/** /**
* DDL for the processing attempt history table. * DDL for the base processing attempt history table.
* <p> * <p>
* <strong>Columns (mandatory fields):</strong> * Base columns (present in all schema versions): id, fingerprint, run_id,
* <ul> * attempt_number, started_at, ended_at, status, failure_class, failure_message, retryable.
* <li>{@code id} — internal surrogate primary key (auto-increment).</li>
* <li>{@code fingerprint} — foreign key reference to
* {@code document_record.fingerprint}; never null.</li>
* <li>{@code run_id} — identifier of the batch run; never null.</li>
* <li>{@code attempt_number} — monotonically increasing per fingerprint, starting
* at 1; never null. The unique constraint on {@code (fingerprint, attempt_number)}
* enforces uniqueness per document.</li>
* <li>{@code started_at} — ISO-8601 UTC timestamp of attempt start; never null.</li>
* <li>{@code ended_at} — ISO-8601 UTC timestamp of attempt end; never null.</li>
* <li>{@code status} — outcome status as enum name string; never null.</li>
* <li>{@code failure_class} — short failure classification; nullable (null for
* success and skip attempts).</li>
* <li>{@code failure_message} — human-readable failure description; nullable
* (null for success and skip attempts).</li>
* <li>{@code retryable} — 1 if the failure is retryable in a later run, 0 otherwise;
* never null. Always 0 for success and skip attempts.</li>
* </ul>
* <p> * <p>
* <strong>Skip attempts:</strong> Skip statuses ({@code SKIPPED_ALREADY_PROCESSED}, * AI traceability columns are added separately via {@code ALTER TABLE} to support
* {@code SKIPPED_FINAL_FAILURE}) are stored as regular rows with {@code retryable = 0} * idempotent evolution from earlier schemas.
* and null failure fields.
* <p>
* <strong>Not included (M5+ fields):</strong> model name, prompt identifier,
* AI raw response, AI reasoning, resolved date, date source, final title,
* final target file name.
*/ */
private static final String DDL_CREATE_PROCESSING_ATTEMPT = """ private static final String DDL_CREATE_PROCESSING_ATTEMPT = """
CREATE TABLE IF NOT EXISTS processing_attempt ( CREATE TABLE IF NOT EXISTS processing_attempt (
@@ -140,6 +124,10 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti
) )
"""; """;
// -------------------------------------------------------------------------
// DDL — indexes
// -------------------------------------------------------------------------
/** Index on {@code processing_attempt.fingerprint} for fast per-document lookups. */ /** Index on {@code processing_attempt.fingerprint} for fast per-document lookups. */
private static final String DDL_IDX_ATTEMPT_FINGERPRINT = private static final String DDL_IDX_ATTEMPT_FINGERPRINT =
"CREATE INDEX IF NOT EXISTS idx_processing_attempt_fingerprint " "CREATE INDEX IF NOT EXISTS idx_processing_attempt_fingerprint "
@@ -155,14 +143,69 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti
"CREATE INDEX IF NOT EXISTS idx_document_record_overall_status " "CREATE INDEX IF NOT EXISTS idx_document_record_overall_status "
+ "ON document_record (overall_status)"; + "ON document_record (overall_status)";
// -------------------------------------------------------------------------
// DDL — columns added to processing_attempt via schema evolution
// -------------------------------------------------------------------------
/**
* Columns to add idempotently to {@code processing_attempt}.
* Each entry is {@code [column_name, column_type]}.
*/
private static final String[][] EVOLUTION_ATTEMPT_COLUMNS = {
{"model_name", "TEXT"},
{"prompt_identifier", "TEXT"},
{"processed_page_count", "INTEGER"},
{"sent_character_count", "INTEGER"},
{"ai_raw_response", "TEXT"},
{"ai_reasoning", "TEXT"},
{"resolved_date", "TEXT"},
{"date_source", "TEXT"},
{"validated_title", "TEXT"},
{"final_target_file_name", "TEXT"},
};
// -------------------------------------------------------------------------
// DDL — columns added to document_record via schema evolution
// -------------------------------------------------------------------------
/**
* Columns to add idempotently to {@code document_record}.
* Each entry is {@code [column_name, column_type]}.
*/
private static final String[][] EVOLUTION_RECORD_COLUMNS = {
{"last_target_path", "TEXT"},
{"last_target_file_name", "TEXT"},
};
// -------------------------------------------------------------------------
// M4→current-schema status migration
// -------------------------------------------------------------------------
/**
* Migrates earlier positive intermediate states in {@code document_record} that were
* recorded as {@code SUCCESS} without a validated naming proposal to {@code READY_FOR_AI},
* so the AI naming pipeline processes them in the next run.
* <p>
* Only rows with {@code overall_status = 'SUCCESS'} that have no corresponding
* {@code processing_attempt} with {@code status = 'PROPOSAL_READY'} are updated.
* This migration is idempotent.
*/
private static final String SQL_MIGRATE_LEGACY_SUCCESS_TO_READY_FOR_AI = """
UPDATE document_record
SET overall_status = 'READY_FOR_AI',
updated_at = datetime('now')
WHERE overall_status = 'SUCCESS'
AND NOT EXISTS (
SELECT 1 FROM processing_attempt pa
WHERE pa.fingerprint = document_record.fingerprint
AND pa.status = 'PROPOSAL_READY'
)
""";
private final String jdbcUrl; private final String jdbcUrl;
/** /**
* Constructs the adapter with the JDBC URL of the SQLite database file. * Constructs the adapter with the JDBC URL of the SQLite database file.
* <p>
* The JDBC URL must be in the form {@code jdbc:sqlite:/path/to/file.db}.
* The file and its parent directories need not exist at construction time;
* SQLite creates them when the connection is first opened.
* *
* @param jdbcUrl the JDBC URL of the SQLite database; must not be null or blank * @param jdbcUrl the JDBC URL of the SQLite database; must not be null or blank
* @throws NullPointerException if {@code jdbcUrl} is null * @throws NullPointerException if {@code jdbcUrl} is null
@@ -177,26 +220,22 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti
} }
/** /**
* Creates or verifies the persistence schema in the SQLite database. * Creates or verifies the persistence schema and performs schema evolution and
* status migration.
* <p> * <p>
* Executes the following DDL statements in order: * Execution order:
* <ol> * <ol>
* <li>Enable foreign key enforcement ({@code PRAGMA foreign_keys = ON})</li> * <li>Enable foreign key enforcement.</li>
* <li>Create {@code document_record} table (if not exists)</li> * <li>Create {@code document_record} table (if not exists).</li>
* <li>Create {@code processing_attempt} table (if not exists)</li> * <li>Create {@code processing_attempt} table (if not exists).</li>
* <li>Create indexes on {@code processing_attempt.fingerprint}, * <li>Create all indexes (if not exist).</li>
* {@code processing_attempt.run_id}, and * <li>Add AI-traceability columns to {@code processing_attempt} (idempotent evolution).</li>
* {@code document_record.overall_status}</li> * <li>Migrate earlier positive intermediate state to {@code READY_FOR_AI} (idempotent).</li>
* </ol> * </ol>
* <p> * <p>
* All statements use {@code IF NOT EXISTS} semantics. Calling this method on an * All steps are safe to run on both fresh and existing databases.
* already-initialised database is safe and produces no changes.
* <p>
* <strong>Timing:</strong> Must be called once at program startup, before the
* batch document processing loop begins.
* *
* @throws DocumentPersistenceException if the schema cannot be created or verified * @throws DocumentPersistenceException if any DDL or migration step fails
* due to a JDBC or SQLite error
*/ */
@Override @Override
public void initializeSchema() { public void initializeSchema() {
@@ -211,7 +250,7 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti
statement.execute(DDL_CREATE_DOCUMENT_RECORD); statement.execute(DDL_CREATE_DOCUMENT_RECORD);
logger.debug("Table 'document_record' created or already present."); logger.debug("Table 'document_record' created or already present.");
// Level 2: processing attempt history // Level 2: processing attempt history (base columns only)
statement.execute(DDL_CREATE_PROCESSING_ATTEMPT); statement.execute(DDL_CREATE_PROCESSING_ATTEMPT);
logger.debug("Table 'processing_attempt' created or already present."); logger.debug("Table 'processing_attempt' created or already present.");
@@ -221,7 +260,20 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti
statement.execute(DDL_IDX_RECORD_STATUS); statement.execute(DDL_IDX_RECORD_STATUS);
logger.debug("Indexes created or already present."); logger.debug("Indexes created or already present.");
logger.info("M4 SQLite schema initialisation completed successfully."); // Schema evolution: add AI-traceability + target-copy columns (idempotent)
evolveTableColumns(connection, "processing_attempt", EVOLUTION_ATTEMPT_COLUMNS);
evolveTableColumns(connection, "document_record", EVOLUTION_RECORD_COLUMNS);
// Status migration: earlier positive intermediate state → READY_FOR_AI
int migrated = statement.executeUpdate(SQL_MIGRATE_LEGACY_SUCCESS_TO_READY_FOR_AI);
if (migrated > 0) {
logger.info("Status migration: {} document(s) migrated from legacy SUCCESS state to READY_FOR_AI.",
migrated);
} else {
logger.debug("Status migration: no documents required migration.");
}
logger.info("SQLite schema initialisation and migration completed successfully.");
} catch (SQLException e) { } catch (SQLException e) {
String message = "Failed to initialise SQLite persistence schema at '" + jdbcUrl + "': " + e.getMessage(); String message = "Failed to initialise SQLite persistence schema at '" + jdbcUrl + "': " + e.getMessage();
@@ -231,9 +283,43 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti
} }
/** /**
* Returns the JDBC URL this adapter uses to connect to the SQLite database. * Idempotently adds the given columns to the specified table.
* <p> * <p>
* Intended for logging and diagnostics only. * For each column that does not yet exist, an {@code ALTER TABLE ... ADD COLUMN}
* statement is executed. Columns that already exist are silently skipped.
*
* @param connection an open JDBC connection to the database
* @param tableName the name of the table to evolve
* @param columns array of {@code [column_name, column_type]} pairs to add
* @throws SQLException if a column addition fails for a reason other than duplicate column
*/
private void evolveTableColumns(Connection connection, String tableName, String[][] columns)
throws SQLException {
java.util.Set<String> existingColumns = new java.util.HashSet<>();
try (ResultSet rs = connection.getMetaData().getColumns(null, null, tableName, null)) {
while (rs.next()) {
existingColumns.add(rs.getString("COLUMN_NAME").toLowerCase());
}
}
for (String[] col : columns) {
String columnName = col[0];
String columnType = col[1];
if (!existingColumns.contains(columnName.toLowerCase())) {
String alterSql = "ALTER TABLE " + tableName + " ADD COLUMN " + columnName + " " + columnType;
try (Statement stmt = connection.createStatement()) {
stmt.execute(alterSql);
}
logger.debug("Schema evolution: added column '{}' to '{}'.", columnName, tableName);
} else {
logger.debug("Schema evolution: column '{}' in '{}' already present, skipped.",
columnName, tableName);
}
}
}
/**
* Returns the JDBC URL this adapter uses to connect to the SQLite database.
* *
* @return the JDBC URL; never null or blank * @return the JDBC URL; never null or blank
*/ */

View File

@@ -0,0 +1,141 @@
package de.gecheckt.pdf.umbenenner.adapter.out.targetcopy;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopyPort;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopyResult;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopySuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopyTechnicalFailure;
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.io.IOException;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.Objects;
/**
* Filesystem-based implementation of {@link TargetFileCopyPort}.
* <p>
* Copies a source PDF to the configured target folder using a two-step approach:
* <ol>
* <li>Write the source content to a temporary file in the target folder.</li>
* <li>Rename/move the temporary file to the final resolved filename.</li>
* </ol>
* The atomic-move option is attempted first. If the filesystem does not support atomic
* moves (e.g., across different volumes), a standard move is used as a fallback.
*
* <h2>Source integrity</h2>
* <p>
* The source file is never modified, moved, or deleted. Only a copy is created.
*
* <h2>Temporary file naming</h2>
* <p>
* The temporary file uses the suffix {@code .tmp} appended to the resolved filename
* and is placed in the same target folder. This ensures the final rename is typically
* an intra-filesystem operation, maximising atomicity.
*
* <h2>Architecture boundary</h2>
* <p>
* All NIO operations are confined to this adapter. No {@code Path} or {@code File}
* types appear in the port interface.
*/
public class FilesystemTargetFileCopyAdapter implements TargetFileCopyPort {
private static final Logger logger = LogManager.getLogger(FilesystemTargetFileCopyAdapter.class);
private final Path targetFolderPath;
/**
* Creates the adapter for the given target folder.
*
* @param targetFolderPath the target folder path; must not be null
* @throws NullPointerException if {@code targetFolderPath} is null
*/
public FilesystemTargetFileCopyAdapter(Path targetFolderPath) {
this.targetFolderPath = Objects.requireNonNull(targetFolderPath, "targetFolderPath must not be null");
}
/**
* Copies the source document to the target folder under the given resolved filename.
* <p>
* The copy is performed via a temporary file ({@code resolvedFilename + ".tmp"}) in
* the target folder followed by a move/rename to the final name.
* <p>
* If any step fails, a best-effort cleanup of the temporary file is attempted
* before returning the failure result.
*
* @param sourceLocator opaque locator identifying the source file; must not be null
* @param resolvedFilename the final filename in the target folder; must not be null or blank
* @return {@link TargetFileCopySuccess} on success, or
* {@link TargetFileCopyTechnicalFailure} on any failure
*/
@Override
public TargetFileCopyResult copyToTarget(SourceDocumentLocator sourceLocator, String resolvedFilename) {
Objects.requireNonNull(sourceLocator, "sourceLocator must not be null");
Objects.requireNonNull(resolvedFilename, "resolvedFilename must not be null");
Path sourcePath = Paths.get(sourceLocator.value());
Path finalTargetPath = targetFolderPath.resolve(resolvedFilename);
Path tempTargetPath = targetFolderPath.resolve(resolvedFilename + ".tmp");
boolean tempCreated = false;
try {
// Step 1: Copy source to temporary file in target folder
Files.copy(sourcePath, tempTargetPath, StandardCopyOption.REPLACE_EXISTING);
tempCreated = true;
logger.debug("Copied source '{}' to temporary file '{}'.",
sourceLocator.value(), tempTargetPath.getFileName());
// Step 2: Atomic move/rename to final target filename
moveToFinalTarget(tempTargetPath, finalTargetPath);
logger.debug("Target copy completed: '{}'.", resolvedFilename);
return new TargetFileCopySuccess();
} catch (Exception e) {
String message = "Failed to copy source '" + sourceLocator.value()
+ "' to target '" + resolvedFilename + "': " + e.getMessage();
logger.error(message, e);
boolean cleaned = tempCreated && tryDeletePath(tempTargetPath);
return new TargetFileCopyTechnicalFailure(message, cleaned);
}
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
/**
* Moves the temporary file to the final target path.
* Attempts an atomic move first; falls back to a standard move if the filesystem
* does not support atomic moves.
*/
private void moveToFinalTarget(Path tempPath, Path finalPath) throws IOException {
try {
Files.move(tempPath, finalPath, StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException e) {
logger.debug("Atomic move not supported, falling back to standard move.");
Files.move(tempPath, finalPath, StandardCopyOption.REPLACE_EXISTING);
}
}
/**
* Best-effort deletion of a path. Returns {@code true} if deletion succeeded
* or the file did not exist; {@code false} if an exception occurred.
*/
private boolean tryDeletePath(Path path) {
try {
Files.deleteIfExists(path);
return true;
} catch (IOException e) {
logger.warn("Best-effort cleanup: could not delete temporary file '{}': {}",
path, e.getMessage());
return false;
}
}
}

View File

@@ -0,0 +1,140 @@
package de.gecheckt.pdf.umbenenner.adapter.out.targetfolder;
import de.gecheckt.pdf.umbenenner.application.port.out.ResolvedTargetFilename;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFilenameResolutionResult;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFolderPort;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFolderTechnicalFailure;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Objects;
/**
* Filesystem-based implementation of {@link TargetFolderPort}.
* <p>
* Resolves unique filenames for the configured target folder by checking for existing
* files and appending a numeric collision-avoidance suffix when necessary.
*
* <h2>Duplicate resolution algorithm</h2>
* <p>
* Given a base name such as {@code 2024-01-15 - Rechnung.pdf}, the adapter checks:
* <ol>
* <li>{@code 2024-01-15 - Rechnung.pdf} — if free, return it.</li>
* <li>{@code 2024-01-15 - Rechnung(1).pdf} — if free, return it.</li>
* <li>{@code 2024-01-15 - Rechnung(2).pdf} — and so on.</li>
* </ol>
* The suffix is inserted immediately before {@code .pdf}.
* The 20-character base-title limit does not apply to the suffix.
*
* <h2>Architecture boundary</h2>
* <p>
* All NIO operations are confined to this adapter. No {@code Path} or {@code File} types
* appear in the port interface.
*/
public class FilesystemTargetFolderAdapter implements TargetFolderPort {
private static final Logger logger = LogManager.getLogger(FilesystemTargetFolderAdapter.class);
/** Maximum number of duplicate suffixes attempted before giving up. */
private static final int MAX_SUFFIX_ATTEMPTS = 9999;
private final Path targetFolderPath;
/**
* Creates the adapter for the given target folder.
*
* @param targetFolderPath the target folder path; must not be null
* @throws NullPointerException if {@code targetFolderPath} is null
*/
public FilesystemTargetFolderAdapter(Path targetFolderPath) {
this.targetFolderPath = Objects.requireNonNull(targetFolderPath, "targetFolderPath must not be null");
}
/**
* Returns the absolute string representation of the target folder path.
* <p>
* Used by the application layer as an opaque target-folder locator for persistence.
*
* @return absolute path string of the target folder; never null or blank
*/
@Override
public String getTargetFolderLocator() {
return targetFolderPath.toAbsolutePath().toString();
}
/**
* Resolves the first available unique filename in the target folder for the given base name.
* <p>
* Checks for {@code baseName} first; if taken, appends {@code (1)}, {@code (2)}, etc.
* directly before {@code .pdf} until a free name is found.
*
* @param baseName the desired filename including {@code .pdf} extension;
* must not be null or blank
* @return a {@link ResolvedTargetFilename} with the first available name, or a
* {@link TargetFolderTechnicalFailure} if folder access fails
*/
@Override
public TargetFilenameResolutionResult resolveUniqueFilename(String baseName) {
Objects.requireNonNull(baseName, "baseName must not be null");
try {
// Try without suffix first
if (!Files.exists(targetFolderPath.resolve(baseName))) {
logger.debug("Resolved target filename without suffix: '{}'", baseName);
return new ResolvedTargetFilename(baseName);
}
// Determine split point: everything before the final ".pdf"
if (!baseName.toLowerCase().endsWith(".pdf")) {
return new TargetFolderTechnicalFailure(
"Base name does not end with .pdf: '" + baseName + "'");
}
String nameWithoutExt = baseName.substring(0, baseName.length() - 4);
// Try (1), (2), ...
for (int i = 1; i <= MAX_SUFFIX_ATTEMPTS; i++) {
String candidate = nameWithoutExt + "(" + i + ").pdf";
if (!Files.exists(targetFolderPath.resolve(candidate))) {
logger.debug("Resolved target filename with suffix ({}): '{}'", i, candidate);
return new ResolvedTargetFilename(candidate);
}
}
return new TargetFolderTechnicalFailure(
"Too many duplicate files for base name '" + baseName
+ "': checked up to suffix (" + MAX_SUFFIX_ATTEMPTS + ")");
} catch (Exception e) {
String message = "Failed to check target folder for duplicate resolution: " + e.getMessage();
logger.error(message, e);
return new TargetFolderTechnicalFailure(message);
}
}
/**
* Best-effort deletion of a file in the target folder.
* <p>
* Used for rollback after a successful copy when subsequent persistence fails.
* Never throws; all exceptions are caught and logged at warn level.
*
* @param resolvedFilename the filename (not full path) to delete; must not be null
*/
@Override
public void tryDeleteTargetFile(String resolvedFilename) {
Objects.requireNonNull(resolvedFilename, "resolvedFilename must not be null");
try {
boolean deleted = Files.deleteIfExists(targetFolderPath.resolve(resolvedFilename));
if (deleted) {
logger.debug("Best-effort rollback: deleted target file '{}'.", resolvedFilename);
} else {
logger.debug("Best-effort rollback: target file '{}' did not exist.", resolvedFilename);
}
} catch (IOException e) {
logger.warn("Best-effort rollback: could not delete target file '{}': {}",
resolvedFilename, e.getMessage());
}
}
}

View File

@@ -464,14 +464,16 @@ class StartConfigurationValidatorTest {
} }
@Test @Test
void validate_failsWhenTargetFolderDoesNotExist() throws Exception { void validate_succeedsWhenTargetFolderDoesNotExistButParentExists() throws Exception {
// target.folder is "anlegbar" (creatable): parent tempDir exists, folder itself does not.
// The validator must create the folder and accept the configuration.
Path sourceFolder = Files.createDirectory(tempDir.resolve("source")); Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite")); Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt")); Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
StartConfiguration config = new StartConfiguration( StartConfiguration config = new StartConfiguration(
sourceFolder, sourceFolder,
tempDir.resolve("nonexistent"), tempDir.resolve("nonexistent-target"),
sqliteFile, sqliteFile,
URI.create("https://api.example.com"), URI.create("https://api.example.com"),
"gpt-4", "gpt-4",
@@ -486,11 +488,43 @@ class StartConfigurationValidatorTest {
"test-api-key" "test-api-key"
); );
assertDoesNotThrow(() -> validator.validate(config),
"Validator must accept a target folder that does not yet exist but can be created");
assertTrue(Files.isDirectory(tempDir.resolve("nonexistent-target")),
"Target folder must have been created by the validator");
}
@Test
void validate_failsWhenTargetFolderCannotBeCreated() {
// Inject a TargetFolderChecker that simulates a creation failure.
StartConfigurationValidator validatorWithFailingChecker = new StartConfigurationValidator(
path -> null, // source folder checker always passes
path -> "- target.folder: path does not exist and could not be created: " + path + " (Permission denied)"
);
StartConfiguration config = new StartConfiguration(
tempDir.resolve("source"),
tempDir.resolve("uncreatable-target"),
tempDir.resolve("db.sqlite"),
URI.create("https://api.example.com"),
"gpt-4",
30,
3,
100,
50000,
tempDir.resolve("prompt.txt"),
null,
null,
"INFO",
"test-api-key"
);
InvalidStartConfigurationException exception = assertThrows( InvalidStartConfigurationException exception = assertThrows(
InvalidStartConfigurationException.class, InvalidStartConfigurationException.class,
() -> validator.validate(config) () -> validatorWithFailingChecker.validate(config)
); );
assertTrue(exception.getMessage().contains("target.folder: path does not exist")); assertTrue(exception.getMessage().contains("target.folder: path does not exist and could not be created"),
"Error message must indicate that the target folder could not be created");
} }
@Test @Test

View File

@@ -74,7 +74,9 @@ class SqliteDocumentRecordRepositoryAdapterTest {
null, null,
null, null,
Instant.now().truncatedTo(ChronoUnit.MICROS), Instant.now().truncatedTo(ChronoUnit.MICROS),
Instant.now().truncatedTo(ChronoUnit.MICROS) Instant.now().truncatedTo(ChronoUnit.MICROS),
null,
null
); );
// When // When
@@ -111,7 +113,9 @@ class SqliteDocumentRecordRepositoryAdapterTest {
null, null,
null, null,
Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.MICROS), Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.MICROS),
Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.MICROS) Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.MICROS),
null,
null
); );
repository.create(initialRecord); repository.create(initialRecord);
@@ -127,7 +131,9 @@ class SqliteDocumentRecordRepositoryAdapterTest {
null, null,
now, now,
initialRecord.createdAt(), initialRecord.createdAt(),
now now,
null,
null
); );
// When // When
@@ -160,7 +166,9 @@ class SqliteDocumentRecordRepositoryAdapterTest {
null, null,
null, null,
Instant.now().truncatedTo(ChronoUnit.MICROS), Instant.now().truncatedTo(ChronoUnit.MICROS),
Instant.now().truncatedTo(ChronoUnit.MICROS) Instant.now().truncatedTo(ChronoUnit.MICROS),
null,
null
); );
repository.create(record1); repository.create(record1);
@@ -174,7 +182,9 @@ class SqliteDocumentRecordRepositoryAdapterTest {
null, null,
null, null,
Instant.now().truncatedTo(ChronoUnit.MICROS), Instant.now().truncatedTo(ChronoUnit.MICROS),
Instant.now().truncatedTo(ChronoUnit.MICROS) Instant.now().truncatedTo(ChronoUnit.MICROS),
null,
null
); );
// When / Then // When / Then
@@ -196,7 +206,9 @@ class SqliteDocumentRecordRepositoryAdapterTest {
null, null,
null, null,
Instant.now().truncatedTo(ChronoUnit.MICROS), Instant.now().truncatedTo(ChronoUnit.MICROS),
Instant.now().truncatedTo(ChronoUnit.MICROS) Instant.now().truncatedTo(ChronoUnit.MICROS),
null,
null
); );
// When / Then // When / Then
@@ -221,7 +233,9 @@ class SqliteDocumentRecordRepositoryAdapterTest {
null, null,
null, null,
now.minusSeconds(120), now.minusSeconds(120),
now.minusSeconds(120) now.minusSeconds(120),
null,
null
); );
repository.create(initialRecord); repository.create(initialRecord);
@@ -236,7 +250,9 @@ class SqliteDocumentRecordRepositoryAdapterTest {
failureInstant, failureInstant,
null, null,
now.minusSeconds(120), now.minusSeconds(120),
failureInstant failureInstant,
null,
null
); );
repository.update(failedFinalRecord); repository.update(failedFinalRecord);
@@ -269,7 +285,9 @@ class SqliteDocumentRecordRepositoryAdapterTest {
null, null,
null, null,
createdAt, createdAt,
createdAt createdAt,
null,
null
); );
repository.create(initialRecord); repository.create(initialRecord);
@@ -284,7 +302,9 @@ class SqliteDocumentRecordRepositoryAdapterTest {
failureInstant, failureInstant,
null, null,
createdAt, createdAt,
failureInstant failureInstant,
null,
null
); );
// When // When
@@ -321,7 +341,9 @@ class SqliteDocumentRecordRepositoryAdapterTest {
firstFailureAt, firstFailureAt,
null, null,
createdAt, createdAt,
firstFailureAt firstFailureAt,
null,
null
); );
repository.create(initialRecord); repository.create(initialRecord);
@@ -336,7 +358,9 @@ class SqliteDocumentRecordRepositoryAdapterTest {
secondFailureAt, secondFailureAt,
null, null,
createdAt, createdAt,
secondFailureAt secondFailureAt,
null,
null
); );
// When // When
@@ -369,7 +393,9 @@ class SqliteDocumentRecordRepositoryAdapterTest {
null, null,
null, null,
createdAt, createdAt,
createdAt createdAt,
null,
null
); );
repository.create(initialRecord); repository.create(initialRecord);
@@ -384,7 +410,9 @@ class SqliteDocumentRecordRepositoryAdapterTest {
failureInstant, failureInstant,
null, null,
createdAt, createdAt,
failureInstant failureInstant,
null,
null
); );
// When // When
@@ -439,7 +467,9 @@ class SqliteDocumentRecordRepositoryAdapterTest {
null, null,
null, null,
now, now,
now now,
null,
null
); );
repository.create(record); repository.create(record);
@@ -467,7 +497,9 @@ class SqliteDocumentRecordRepositoryAdapterTest {
now.minusSeconds(60), now.minusSeconds(60),
null, null,
now, now,
now now,
null,
null
); );
repository.create(record); repository.create(record);
@@ -495,7 +527,9 @@ class SqliteDocumentRecordRepositoryAdapterTest {
null, // lastFailureInstant is null null, // lastFailureInstant is null
null, // lastSuccessInstant is null null, // lastSuccessInstant is null
now, now,
now now,
null,
null
); );
repository.create(record); repository.create(record);
@@ -509,6 +543,76 @@ class SqliteDocumentRecordRepositoryAdapterTest {
assertThat(known.record().lastSuccessInstant()).isNull(); assertThat(known.record().lastSuccessInstant()).isNull();
} }
@Test
void create_and_update_shouldPersistAndReadTargetPathAndTargetFileName() {
// Given: create a record with null target fields initially
DocumentFingerprint fingerprint = new DocumentFingerprint(
"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd");
Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
DocumentRecord initialRecord = new DocumentRecord(
fingerprint,
new SourceDocumentLocator("/source/doc.pdf"),
"doc.pdf",
ProcessingStatus.PROCESSING,
FailureCounters.zero(),
null, null,
now, now,
null, null
);
repository.create(initialRecord);
// Update with target path and filename
DocumentRecord successRecord = new DocumentRecord(
fingerprint,
new SourceDocumentLocator("/source/doc.pdf"),
"doc.pdf",
ProcessingStatus.SUCCESS,
FailureCounters.zero(),
null, now,
now, now,
"/target/folder",
"2026-01-15 - Rechnung.pdf"
);
// When
repository.update(successRecord);
DocumentRecordLookupResult result = repository.findByFingerprint(fingerprint);
// Then
assertThat(result).isInstanceOf(DocumentTerminalSuccess.class);
DocumentRecord found = ((DocumentTerminalSuccess) result).record();
assertThat(found.lastTargetPath()).isEqualTo("/target/folder");
assertThat(found.lastTargetFileName()).isEqualTo("2026-01-15 - Rechnung.pdf");
}
@Test
void update_shouldPersistNullTargetFields_whenNotYetCopied() {
// Given: a record with null target path and filename (not yet in SUCCESS)
DocumentFingerprint fingerprint = new DocumentFingerprint(
"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee");
Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
DocumentRecord record = new DocumentRecord(
fingerprint,
new SourceDocumentLocator("/source/pending.pdf"),
"pending.pdf",
ProcessingStatus.FAILED_RETRYABLE,
new FailureCounters(0, 1),
now, null,
now, now,
null, null
);
repository.create(record);
// When
DocumentRecordLookupResult result = repository.findByFingerprint(fingerprint);
// Then
assertThat(result).isInstanceOf(DocumentKnownProcessable.class);
DocumentRecord found = ((DocumentKnownProcessable) result).record();
assertThat(found.lastTargetPath()).isNull();
assertThat(found.lastTargetFileName()).isNull();
}
@Test @Test
void update_shouldPreserveCreatedAtTimestamp() { void update_shouldPreserveCreatedAtTimestamp() {
// Given: create with specific createdAt // Given: create with specific createdAt
@@ -526,7 +630,9 @@ class SqliteDocumentRecordRepositoryAdapterTest {
null, null,
null, null,
createdAt, // Much older createdAt createdAt, // Much older createdAt
createdAt createdAt,
null,
null
); );
repository.create(initialRecord); repository.create(initialRecord);
@@ -540,7 +646,9 @@ class SqliteDocumentRecordRepositoryAdapterTest {
null, null,
now, now,
createdAt, // createdAt should remain unchanged createdAt, // createdAt should remain unchanged
now now,
null,
null
); );
// When // When

View File

@@ -8,6 +8,7 @@ import java.sql.Connection;
import java.sql.DriverManager; import java.sql.DriverManager;
import java.sql.SQLException; import java.sql.SQLException;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
import java.util.List; import java.util.List;
@@ -17,14 +18,16 @@ import org.junit.jupiter.api.io.TempDir;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException; import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt; import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
import de.gecheckt.pdf.umbenenner.domain.model.DateSource;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint; import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus; import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
import de.gecheckt.pdf.umbenenner.domain.model.RunId; import de.gecheckt.pdf.umbenenner.domain.model.RunId;
/** /**
* Tests for {@link SqliteProcessingAttemptRepositoryAdapter}. * Tests for {@link SqliteProcessingAttemptRepositoryAdapter}.
* * <p>
* @since M4-AP-005 * Covers base attempt persistence, AI traceability field round-trips,
* proposal-ready lookup, and non-AI-attempt status storability.
*/ */
class SqliteProcessingAttemptRepositoryAdapterTest { class SqliteProcessingAttemptRepositoryAdapterTest {
@@ -101,7 +104,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
insertDocumentRecord(fingerprint); insertDocumentRecord(fingerprint);
// Insert first attempt // Insert first attempt
ProcessingAttempt firstAttempt = new ProcessingAttempt( ProcessingAttempt firstAttempt = ProcessingAttempt.withoutAiFields(
fingerprint, fingerprint,
runId, runId,
1, 1,
@@ -134,7 +137,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
// Insert multiple attempts // Insert multiple attempts
for (int i = 1; i <= 5; i++) { for (int i = 1; i <= 5; i++) {
ProcessingAttempt attempt = new ProcessingAttempt( ProcessingAttempt attempt = ProcessingAttempt.withoutAiFields(
fingerprint, fingerprint,
runId, runId,
i, i,
@@ -178,7 +181,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
// Insert a document record first (FK constraint) // Insert a document record first (FK constraint)
insertDocumentRecord(fingerprint); insertDocumentRecord(fingerprint);
ProcessingAttempt attempt = new ProcessingAttempt( ProcessingAttempt attempt = ProcessingAttempt.withoutAiFields(
fingerprint, fingerprint,
runId, runId,
1, 1,
@@ -221,7 +224,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
// Insert a document record first (FK constraint) // Insert a document record first (FK constraint)
insertDocumentRecord(fingerprint); insertDocumentRecord(fingerprint);
ProcessingAttempt attempt = new ProcessingAttempt( ProcessingAttempt attempt = ProcessingAttempt.withoutAiFields(
fingerprint, fingerprint,
runId, runId,
1, 1,
@@ -283,7 +286,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
insertDocumentRecord(fingerprint); insertDocumentRecord(fingerprint);
// Insert attempts out of order to verify sorting // Insert attempts out of order to verify sorting
ProcessingAttempt attempt3 = new ProcessingAttempt( ProcessingAttempt attempt3 = ProcessingAttempt.withoutAiFields(
fingerprint, fingerprint,
runId2, runId2,
3, 3,
@@ -296,7 +299,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
); );
repository.save(attempt3); repository.save(attempt3);
ProcessingAttempt attempt1 = new ProcessingAttempt( ProcessingAttempt attempt1 = ProcessingAttempt.withoutAiFields(
fingerprint, fingerprint,
runId1, runId1,
1, 1,
@@ -309,7 +312,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
); );
repository.save(attempt1); repository.save(attempt1);
ProcessingAttempt attempt2 = new ProcessingAttempt( ProcessingAttempt attempt2 = ProcessingAttempt.withoutAiFields(
fingerprint, fingerprint,
runId1, runId1,
2, 2,
@@ -368,6 +371,388 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
.hasMessageContaining("fingerprint"); .hasMessageContaining("fingerprint");
} }
// -------------------------------------------------------------------------
// AI traceability fields — round-trip persistence
// -------------------------------------------------------------------------
@Test
void save_persistsAllAiTraceabilityFields_andFindAllReadsThemBack() {
// Given
DocumentFingerprint fingerprint = new DocumentFingerprint(
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
RunId runId = new RunId("ai-run-1");
Instant startedAt = Instant.now().minusSeconds(30).truncatedTo(ChronoUnit.MICROS);
Instant endedAt = Instant.now().truncatedTo(ChronoUnit.MICROS);
LocalDate resolvedDate = LocalDate.of(2026, 3, 15);
insertDocumentRecord(fingerprint);
ProcessingAttempt attempt = new ProcessingAttempt(
fingerprint, runId, 1, startedAt, endedAt,
ProcessingStatus.PROPOSAL_READY,
null, null, false,
"gpt-4o", "prompt-v1.txt",
5, 1234,
"{\"date\":\"2026-03-15\",\"title\":\"Stromabrechnung\",\"reasoning\":\"Invoice date found.\"}",
"Invoice date found.",
resolvedDate, DateSource.AI_PROVIDED,
"Stromabrechnung",
null
);
// When
repository.save(attempt);
// Then
List<ProcessingAttempt> saved = repository.findAllByFingerprint(fingerprint);
assertThat(saved).hasSize(1);
ProcessingAttempt result = saved.get(0);
assertThat(result.modelName()).isEqualTo("gpt-4o");
assertThat(result.promptIdentifier()).isEqualTo("prompt-v1.txt");
assertThat(result.processedPageCount()).isEqualTo(5);
assertThat(result.sentCharacterCount()).isEqualTo(1234);
assertThat(result.aiRawResponse()).contains("Stromabrechnung");
assertThat(result.aiReasoning()).isEqualTo("Invoice date found.");
assertThat(result.resolvedDate()).isEqualTo(resolvedDate);
assertThat(result.dateSource()).isEqualTo(DateSource.AI_PROVIDED);
assertThat(result.validatedTitle()).isEqualTo("Stromabrechnung");
}
@Test
void save_persistsAiFieldsWithFallbackDateSource() {
// Given
DocumentFingerprint fingerprint = new DocumentFingerprint(
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
RunId runId = new RunId("ai-run-2");
Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
LocalDate fallbackDate = LocalDate.of(2026, 4, 7);
insertDocumentRecord(fingerprint);
ProcessingAttempt attempt = new ProcessingAttempt(
fingerprint, runId, 1, now, now.plusSeconds(5),
ProcessingStatus.PROPOSAL_READY,
null, null, false,
"claude-sonnet-4-6", "prompt-v2.txt",
3, 800,
"{\"title\":\"Kontoauszug\",\"reasoning\":\"No date in document.\"}",
"No date in document.",
fallbackDate, DateSource.FALLBACK_CURRENT,
"Kontoauszug",
null
);
repository.save(attempt);
List<ProcessingAttempt> saved = repository.findAllByFingerprint(fingerprint);
assertThat(saved).hasSize(1);
ProcessingAttempt result = saved.get(0);
assertThat(result.dateSource()).isEqualTo(DateSource.FALLBACK_CURRENT);
assertThat(result.resolvedDate()).isEqualTo(fallbackDate);
}
@Test
void save_persistsNullAiFields_whenNoAiCallWasMade() {
// Given
DocumentFingerprint fingerprint = new DocumentFingerprint(
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc");
RunId runId = new RunId("no-ai-run");
Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
insertDocumentRecord(fingerprint);
ProcessingAttempt attempt = ProcessingAttempt.withoutAiFields(
fingerprint, runId, 1, now, now.plusSeconds(1),
ProcessingStatus.FAILED_RETRYABLE,
"NoTextError", "No extractable text", true
);
repository.save(attempt);
List<ProcessingAttempt> saved = repository.findAllByFingerprint(fingerprint);
assertThat(saved).hasSize(1);
ProcessingAttempt result = saved.get(0);
assertThat(result.modelName()).isNull();
assertThat(result.promptIdentifier()).isNull();
assertThat(result.processedPageCount()).isNull();
assertThat(result.sentCharacterCount()).isNull();
assertThat(result.aiRawResponse()).isNull();
assertThat(result.aiReasoning()).isNull();
assertThat(result.resolvedDate()).isNull();
assertThat(result.dateSource()).isNull();
assertThat(result.validatedTitle()).isNull();
}
// -------------------------------------------------------------------------
// findLatestProposalReadyAttempt
// -------------------------------------------------------------------------
@Test
void findLatestProposalReadyAttempt_returnsNull_whenNoAttemptsExist() {
DocumentFingerprint fingerprint = new DocumentFingerprint(
"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd");
ProcessingAttempt result = repository.findLatestProposalReadyAttempt(fingerprint);
assertThat(result).isNull();
}
@Test
void findLatestProposalReadyAttempt_returnsNull_whenNoProposalReadyAttemptExists() {
DocumentFingerprint fingerprint = new DocumentFingerprint(
"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee");
Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
insertDocumentRecord(fingerprint);
ProcessingAttempt attempt = ProcessingAttempt.withoutAiFields(
fingerprint, new RunId("run-x"), 1, now, now.plusSeconds(1),
ProcessingStatus.FAILED_RETRYABLE, "Err", "msg", true
);
repository.save(attempt);
ProcessingAttempt result = repository.findLatestProposalReadyAttempt(fingerprint);
assertThat(result).isNull();
}
@Test
void findLatestProposalReadyAttempt_returnsSingleProposalReadyAttempt() {
DocumentFingerprint fingerprint = new DocumentFingerprint(
"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff");
Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
LocalDate date = LocalDate.of(2026, 2, 1);
insertDocumentRecord(fingerprint);
ProcessingAttempt attempt = new ProcessingAttempt(
fingerprint, new RunId("run-p"), 1, now, now.plusSeconds(2),
ProcessingStatus.PROPOSAL_READY,
null, null, false,
"gpt-4o", "prompt-v1.txt", 2, 500,
"{\"title\":\"Rechnung\",\"reasoning\":\"Found.\"}",
"Found.", date, DateSource.AI_PROVIDED, "Rechnung",
null
);
repository.save(attempt);
ProcessingAttempt result = repository.findLatestProposalReadyAttempt(fingerprint);
assertThat(result).isNotNull();
assertThat(result.status()).isEqualTo(ProcessingStatus.PROPOSAL_READY);
assertThat(result.validatedTitle()).isEqualTo("Rechnung");
assertThat(result.resolvedDate()).isEqualTo(date);
assertThat(result.dateSource()).isEqualTo(DateSource.AI_PROVIDED);
}
@Test
void findLatestProposalReadyAttempt_returnsLatest_whenMultipleExist() {
DocumentFingerprint fingerprint = new DocumentFingerprint(
"1111111111111111111111111111111111111111111111111111111111111112");
Instant base = Instant.now().truncatedTo(ChronoUnit.MICROS);
insertDocumentRecord(fingerprint);
// First PROPOSAL_READY attempt
repository.save(new ProcessingAttempt(
fingerprint, new RunId("run-1"), 1, base, base.plusSeconds(1),
ProcessingStatus.PROPOSAL_READY,
null, null, false,
"model-a", "prompt-v1.txt", 1, 100,
"{}", "First.", LocalDate.of(2026, 1, 1), DateSource.AI_PROVIDED, "TitelEins",
null
));
// Subsequent FAILED attempt
repository.save(ProcessingAttempt.withoutAiFields(
fingerprint, new RunId("run-2"), 2,
base.plusSeconds(10), base.plusSeconds(11),
ProcessingStatus.FAILED_RETRYABLE, "Err", "msg", true
));
// Second PROPOSAL_READY attempt (newer)
repository.save(new ProcessingAttempt(
fingerprint, new RunId("run-3"), 3, base.plusSeconds(20), base.plusSeconds(21),
ProcessingStatus.PROPOSAL_READY,
null, null, false,
"model-b", "prompt-v2.txt", 2, 200,
"{}", "Second.", LocalDate.of(2026, 2, 2), DateSource.AI_PROVIDED, "TitelZwei",
null
));
ProcessingAttempt result = repository.findLatestProposalReadyAttempt(fingerprint);
assertThat(result).isNotNull();
assertThat(result.attemptNumber()).isEqualTo(3);
assertThat(result.validatedTitle()).isEqualTo("TitelZwei");
assertThat(result.modelName()).isEqualTo("model-b");
}
@Test
void save_persistsFinalTargetFileName_forSuccessAttempt() {
// Given
DocumentFingerprint fingerprint = new DocumentFingerprint(
"4444444444444444444444444444444444444444444444444444444444444445");
RunId runId = new RunId("success-run");
Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
LocalDate date = LocalDate.of(2026, 1, 15);
String expectedFileName = "2026-01-15 - Rechnung.pdf";
insertDocumentRecord(fingerprint);
ProcessingAttempt attempt = new ProcessingAttempt(
fingerprint, runId, 1, now, now.plusSeconds(3),
ProcessingStatus.SUCCESS,
null, null, false,
"gpt-4", "prompt-v1.txt", 2, 600,
"{\"title\":\"Rechnung\",\"reasoning\":\"Invoice.\"}",
"Invoice.",
date, DateSource.AI_PROVIDED,
"Rechnung",
expectedFileName
);
// When
repository.save(attempt);
// Then
List<ProcessingAttempt> saved = repository.findAllByFingerprint(fingerprint);
assertThat(saved).hasSize(1);
assertThat(saved.get(0).finalTargetFileName()).isEqualTo(expectedFileName);
assertThat(saved.get(0).status()).isEqualTo(ProcessingStatus.SUCCESS);
}
@Test
void save_persistsNullFinalTargetFileName_forNonSuccessAttempt() {
// finalTargetFileName must remain null for PROPOSAL_READY and non-SUCCESS attempts
DocumentFingerprint fingerprint = new DocumentFingerprint(
"5555555555555555555555555555555555555555555555555555555555555556");
Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
insertDocumentRecord(fingerprint);
ProcessingAttempt attempt = new ProcessingAttempt(
fingerprint, new RunId("run-prop"), 1, now, now.plusSeconds(1),
ProcessingStatus.PROPOSAL_READY,
null, null, false,
"gpt-4", "prompt-v1.txt", 1, 200,
"{}", "reason",
LocalDate.of(2026, 3, 1), DateSource.AI_PROVIDED,
"Kontoauszug",
null // no target filename yet
);
repository.save(attempt);
List<ProcessingAttempt> saved = repository.findAllByFingerprint(fingerprint);
assertThat(saved).hasSize(1);
assertThat(saved.get(0).finalTargetFileName()).isNull();
}
@Test
void save_proposalAttemptNotOverwrittenBySubsequentSuccessAttempt() {
// Verifies that the leading PROPOSAL_READY attempt remains unchanged when
// a subsequent SUCCESS attempt is added (no update, only new insert).
DocumentFingerprint fingerprint = new DocumentFingerprint(
"6666666666666666666666666666666666666666666666666666666666666667");
Instant base = Instant.now().truncatedTo(ChronoUnit.MICROS);
LocalDate date = LocalDate.of(2026, 2, 10);
insertDocumentRecord(fingerprint);
// First attempt: PROPOSAL_READY
ProcessingAttempt proposalAttempt = new ProcessingAttempt(
fingerprint, new RunId("run-1"), 1, base, base.plusSeconds(2),
ProcessingStatus.PROPOSAL_READY,
null, null, false,
"model-a", "prompt-v1.txt", 3, 700,
"{}", "reason.", date, DateSource.AI_PROVIDED, "Bescheid", null
);
repository.save(proposalAttempt);
// Second attempt: SUCCESS (target copy completed)
ProcessingAttempt successAttempt = new ProcessingAttempt(
fingerprint, new RunId("run-1"), 2,
base.plusSeconds(5), base.plusSeconds(6),
ProcessingStatus.SUCCESS,
null, null, false,
null, null, null, null, null, null,
null, null, null,
"2026-02-10 - Bescheid.pdf"
);
repository.save(successAttempt);
// Both attempts must be present
List<ProcessingAttempt> all = repository.findAllByFingerprint(fingerprint);
assertThat(all).hasSize(2);
// The original PROPOSAL_READY attempt must remain unchanged
ProcessingAttempt first = all.get(0);
assertThat(first.status()).isEqualTo(ProcessingStatus.PROPOSAL_READY);
assertThat(first.validatedTitle()).isEqualTo("Bescheid");
assertThat(first.finalTargetFileName()).isNull();
// The SUCCESS attempt carries the final filename
ProcessingAttempt second = all.get(1);
assertThat(second.status()).isEqualTo(ProcessingStatus.SUCCESS);
assertThat(second.finalTargetFileName()).isEqualTo("2026-02-10 - Bescheid.pdf");
}
@Test
void findLatestProposalReadyAttempt_rejectsNullFingerprint() {
assertThatThrownBy(() -> repository.findLatestProposalReadyAttempt(null))
.isInstanceOf(NullPointerException.class)
.hasMessageContaining("fingerprint");
}
// -------------------------------------------------------------------------
// READY_FOR_AI and PROPOSAL_READY status storability
// -------------------------------------------------------------------------
@Test
void save_canPersistReadyForAiStatus() {
DocumentFingerprint fingerprint = new DocumentFingerprint(
"2222222222222222222222222222222222222222222222222222222222222223");
Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
insertDocumentRecord(fingerprint);
ProcessingAttempt attempt = ProcessingAttempt.withoutAiFields(
fingerprint, new RunId("run-r"), 1, now, now.plusSeconds(1),
ProcessingStatus.READY_FOR_AI, null, null, false
);
repository.save(attempt);
List<ProcessingAttempt> saved = repository.findAllByFingerprint(fingerprint);
assertThat(saved).hasSize(1);
assertThat(saved.get(0).status()).isEqualTo(ProcessingStatus.READY_FOR_AI);
}
@Test
void save_canPersistProposalReadyStatus() {
DocumentFingerprint fingerprint = new DocumentFingerprint(
"3333333333333333333333333333333333333333333333333333333333333334");
Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
insertDocumentRecord(fingerprint);
ProcessingAttempt attempt = new ProcessingAttempt(
fingerprint, new RunId("run-p2"), 1, now, now.plusSeconds(1),
ProcessingStatus.PROPOSAL_READY,
null, null, false,
"model-x", "prompt-v1.txt", 1, 50,
"{}", "Reasoning.", LocalDate.of(2026, 1, 15), DateSource.AI_PROVIDED, "Titel",
null
);
repository.save(attempt);
List<ProcessingAttempt> saved = repository.findAllByFingerprint(fingerprint);
assertThat(saved).hasSize(1);
assertThat(saved.get(0).status()).isEqualTo(ProcessingStatus.PROPOSAL_READY);
}
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Integration with document records (FK constraints) // Integration with document records (FK constraints)
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -380,7 +765,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
RunId runId = new RunId("test-run-7"); RunId runId = new RunId("test-run-7");
Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS); Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
ProcessingAttempt attempt = new ProcessingAttempt( ProcessingAttempt attempt = ProcessingAttempt.withoutAiFields(
fingerprint, fingerprint,
runId, runId,
1, 1,

View File

@@ -18,12 +18,12 @@ import org.junit.jupiter.api.io.TempDir;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException; import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
/** /**
* Unit tests for {@link SqliteSchemaInitializationAdapter}. * Tests for {@link SqliteSchemaInitializationAdapter}.
* <p> * <p>
* Verifies that the M4 two-level schema is created correctly, that the operation * Verifies that the two-level schema is created correctly, that schema evolution
* is idempotent, and that invalid configuration is rejected. * (idempotent addition of AI traceability columns) works, that the idempotent
* * status migration of earlier positive intermediate states to {@code READY_FOR_AI}
* @since M4-AP-003 * is correct, and that invalid configuration is rejected.
*/ */
class SqliteSchemaInitializationAdapterTest { class SqliteSchemaInitializationAdapterTest {
@@ -87,7 +87,9 @@ class SqliteSchemaInitializationAdapterTest {
"last_failure_instant", "last_failure_instant",
"last_success_instant", "last_success_instant",
"created_at", "created_at",
"updated_at" "updated_at",
"last_target_path",
"last_target_file_name"
); );
} }
@@ -107,7 +109,17 @@ class SqliteSchemaInitializationAdapterTest {
"status", "status",
"failure_class", "failure_class",
"failure_message", "failure_message",
"retryable" "retryable",
"model_name",
"prompt_identifier",
"processed_page_count",
"sent_character_count",
"ai_raw_response",
"ai_reasoning",
"resolved_date",
"date_source",
"validated_title",
"final_target_file_name"
); );
} }
@@ -239,6 +251,130 @@ class SqliteSchemaInitializationAdapterTest {
} }
} }
// -------------------------------------------------------------------------
// Schema evolution — AI traceability columns
// -------------------------------------------------------------------------
@Test
void initializeSchema_addsAiTraceabilityColumnsToExistingSchema(@TempDir Path dir)
throws SQLException {
// Simulate a pre-evolution schema: create the base tables without AI columns
String jdbcUrl = jdbcUrl(dir, "evolution_test.db");
try (Connection conn = DriverManager.getConnection(jdbcUrl);
var stmt = conn.createStatement()) {
stmt.execute("""
CREATE TABLE IF NOT EXISTS document_record (
id INTEGER PRIMARY KEY AUTOINCREMENT,
fingerprint TEXT NOT NULL,
last_known_source_locator TEXT NOT NULL,
last_known_source_file_name TEXT NOT NULL,
overall_status TEXT NOT NULL,
content_error_count INTEGER NOT NULL DEFAULT 0,
transient_error_count INTEGER NOT NULL DEFAULT 0,
last_failure_instant TEXT,
last_success_instant TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
CONSTRAINT uq_document_record_fingerprint UNIQUE (fingerprint)
)
""");
stmt.execute("""
CREATE TABLE IF NOT EXISTS processing_attempt (
id INTEGER PRIMARY KEY AUTOINCREMENT,
fingerprint TEXT NOT NULL,
run_id TEXT NOT NULL,
attempt_number INTEGER NOT NULL,
started_at TEXT NOT NULL,
ended_at TEXT NOT NULL,
status TEXT NOT NULL,
failure_class TEXT,
failure_message TEXT,
retryable INTEGER NOT NULL DEFAULT 0
)
""");
}
// Running initializeSchema on the existing base schema must succeed (evolution)
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
Set<String> columns = readColumnNames(jdbcUrl, "processing_attempt");
assertThat(columns).contains(
"model_name", "prompt_identifier", "processed_page_count",
"sent_character_count", "ai_raw_response", "ai_reasoning",
"resolved_date", "date_source", "validated_title");
}
// -------------------------------------------------------------------------
// Status migration — earlier positive intermediate state → READY_FOR_AI
// -------------------------------------------------------------------------
@Test
void initializeSchema_migrates_legacySuccessWithoutProposal_toReadyForAi(@TempDir Path dir)
throws SQLException {
String jdbcUrl = jdbcUrl(dir, "migration_test.db");
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
// Insert a document with SUCCESS status and no PROPOSAL_READY attempt
String fp = "d".repeat(64);
insertDocumentRecordWithStatus(jdbcUrl, fp, "SUCCESS");
// Run schema initialisation again (migration step runs every time)
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
String status = readOverallStatus(jdbcUrl, fp);
assertThat(status).isEqualTo("READY_FOR_AI");
}
@Test
void initializeSchema_migration_isIdempotent(@TempDir Path dir) throws SQLException {
String jdbcUrl = jdbcUrl(dir, "migration_idempotent_test.db");
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
String fp = "e".repeat(64);
insertDocumentRecordWithStatus(jdbcUrl, fp, "SUCCESS");
// Run migration twice — must not corrupt data or throw
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
String status = readOverallStatus(jdbcUrl, fp);
assertThat(status).isEqualTo("READY_FOR_AI");
}
@Test
void initializeSchema_doesNotMigrate_successWithProposalReadyAttempt(@TempDir Path dir)
throws SQLException {
String jdbcUrl = jdbcUrl(dir, "migration_proposal_test.db");
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
String fp = "f".repeat(64);
// SUCCESS document that already has a PROPOSAL_READY attempt must NOT be migrated
insertDocumentRecordWithStatus(jdbcUrl, fp, "SUCCESS");
insertAttemptWithStatus(jdbcUrl, fp, "PROPOSAL_READY");
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
String status = readOverallStatus(jdbcUrl, fp);
assertThat(status).isEqualTo("SUCCESS");
}
@Test
void initializeSchema_doesNotMigrate_terminalFailureStates(@TempDir Path dir)
throws SQLException {
String jdbcUrl = jdbcUrl(dir, "migration_failure_test.db");
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
String fpRetryable = "1".repeat(64);
String fpFinal = "2".repeat(64);
insertDocumentRecordWithStatus(jdbcUrl, fpRetryable, "FAILED_RETRYABLE");
insertDocumentRecordWithStatus(jdbcUrl, fpFinal, "FAILED_FINAL");
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
assertThat(readOverallStatus(jdbcUrl, fpRetryable)).isEqualTo("FAILED_RETRYABLE");
assertThat(readOverallStatus(jdbcUrl, fpFinal)).isEqualTo("FAILED_FINAL");
}
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Error handling // Error handling
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -286,4 +422,47 @@ class SqliteSchemaInitializationAdapterTest {
} }
return columns; return columns;
} }
private static void insertDocumentRecordWithStatus(String jdbcUrl, String fingerprint,
String status) throws SQLException {
try (Connection conn = DriverManager.getConnection(jdbcUrl);
var ps = conn.prepareStatement("""
INSERT INTO document_record
(fingerprint, last_known_source_locator, last_known_source_file_name,
overall_status, created_at, updated_at)
VALUES (?, '/src', 'doc.pdf', ?, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')
""")) {
ps.setString(1, fingerprint);
ps.setString(2, status);
ps.executeUpdate();
}
}
private static void insertAttemptWithStatus(String jdbcUrl, String fingerprint,
String status) throws SQLException {
try (Connection conn = DriverManager.getConnection(jdbcUrl);
var ps = conn.prepareStatement("""
INSERT INTO processing_attempt
(fingerprint, run_id, attempt_number, started_at, ended_at, status, retryable)
VALUES (?, 'run-1', 1, '2026-01-01T00:00:00Z', '2026-01-01T00:01:00Z', ?, 0)
""")) {
ps.setString(1, fingerprint);
ps.setString(2, status);
ps.executeUpdate();
}
}
private static String readOverallStatus(String jdbcUrl, String fingerprint) throws SQLException {
try (Connection conn = DriverManager.getConnection(jdbcUrl);
var ps = conn.prepareStatement(
"SELECT overall_status FROM document_record WHERE fingerprint = ?")) {
ps.setString(1, fingerprint);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
return rs.getString("overall_status");
}
throw new IllegalStateException("No document record found for fingerprint: " + fingerprint);
}
}
}
} }

View File

@@ -112,7 +112,9 @@ class SqliteUnitOfWorkAdapterTest {
null, null,
null, null,
now, now,
now now,
null,
null
); );
// Create repositories for verification // Create repositories for verification
@@ -151,7 +153,9 @@ class SqliteUnitOfWorkAdapterTest {
null, null,
null, null,
now, now,
now now,
null,
null
); );
RuntimeException customException = new RuntimeException("Custom runtime error"); RuntimeException customException = new RuntimeException("Custom runtime error");

View File

@@ -0,0 +1,229 @@
package de.gecheckt.pdf.umbenenner.adapter.out.targetcopy;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopyResult;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopySuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopyTechnicalFailure;
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
/**
* Tests for {@link FilesystemTargetFileCopyAdapter}.
* <p>
* Covers the happy path (copy via temp file and final move), source integrity,
* technical failure cases, and cleanup after failure.
*/
class FilesystemTargetFileCopyAdapterTest {
@TempDir
Path sourceFolder;
@TempDir
Path targetFolder;
private FilesystemTargetFileCopyAdapter adapter;
@BeforeEach
void setUp() {
adapter = new FilesystemTargetFileCopyAdapter(targetFolder);
}
// -------------------------------------------------------------------------
// Happy path successful copy
// -------------------------------------------------------------------------
@Test
void copyToTarget_success_returnsTargetFileCopySuccess() throws IOException {
Path sourceFile = createSourceFile("source.pdf", "PDF content");
String resolvedFilename = "2026-01-15 - Rechnung.pdf";
TargetFileCopyResult result = adapter.copyToTarget(
new SourceDocumentLocator(sourceFile.toAbsolutePath().toString()),
resolvedFilename);
assertThat(result).isInstanceOf(TargetFileCopySuccess.class);
}
@Test
void copyToTarget_success_targetFileCreatedWithCorrectContent() throws IOException {
byte[] content = "PDF content bytes".getBytes();
Path sourceFile = sourceFolder.resolve("invoice.pdf");
Files.write(sourceFile, content);
String resolvedFilename = "2026-01-15 - Rechnung.pdf";
adapter.copyToTarget(
new SourceDocumentLocator(sourceFile.toAbsolutePath().toString()),
resolvedFilename);
Path targetFile = targetFolder.resolve(resolvedFilename);
assertThat(targetFile).exists();
assertThat(Files.readAllBytes(targetFile)).isEqualTo(content);
}
@Test
void copyToTarget_success_sourceFileRemainsUnchanged() throws IOException {
byte[] originalContent = "original PDF content".getBytes();
Path sourceFile = sourceFolder.resolve("source.pdf");
Files.write(sourceFile, originalContent);
String resolvedFilename = "2026-01-15 - Rechnung.pdf";
adapter.copyToTarget(
new SourceDocumentLocator(sourceFile.toAbsolutePath().toString()),
resolvedFilename);
// Source must remain completely unchanged
assertThat(Files.readAllBytes(sourceFile)).isEqualTo(originalContent);
assertThat(sourceFile).exists();
}
@Test
void copyToTarget_success_noTempFileRemainsInTargetFolder() throws IOException {
Path sourceFile = createSourceFile("source.pdf", "content");
String resolvedFilename = "2026-04-07 - Bescheid.pdf";
adapter.copyToTarget(
new SourceDocumentLocator(sourceFile.toAbsolutePath().toString()),
resolvedFilename);
// The .tmp file must not remain after a successful copy
Path tempFile = targetFolder.resolve(resolvedFilename + ".tmp");
assertThat(tempFile).doesNotExist();
}
@Test
void copyToTarget_success_finalFileNameIsResolved() throws IOException {
Path sourceFile = createSourceFile("source.pdf", "data");
String resolvedFilename = "2026-03-05 - Kontoauszug.pdf";
adapter.copyToTarget(
new SourceDocumentLocator(sourceFile.toAbsolutePath().toString()),
resolvedFilename);
assertThat(targetFolder.resolve(resolvedFilename)).exists();
}
// -------------------------------------------------------------------------
// Technical failure source file does not exist
// -------------------------------------------------------------------------
@Test
void copyToTarget_sourceDoesNotExist_returnsTargetFileCopyTechnicalFailure() {
String nonExistentSource = sourceFolder.resolve("nonexistent.pdf").toAbsolutePath().toString();
TargetFileCopyResult result = adapter.copyToTarget(
new SourceDocumentLocator(nonExistentSource),
"2026-01-01 - Rechnung.pdf");
assertThat(result).isInstanceOf(TargetFileCopyTechnicalFailure.class);
}
@Test
void copyToTarget_sourceDoesNotExist_failureContainsSourcePath() {
String nonExistentSource = sourceFolder.resolve("nonexistent.pdf").toAbsolutePath().toString();
TargetFileCopyResult result = adapter.copyToTarget(
new SourceDocumentLocator(nonExistentSource),
"2026-01-01 - Rechnung.pdf");
assertThat(result).isInstanceOf(TargetFileCopyTechnicalFailure.class);
TargetFileCopyTechnicalFailure failure = (TargetFileCopyTechnicalFailure) result;
assertThat(failure.errorMessage()).contains(nonExistentSource);
}
// -------------------------------------------------------------------------
// Technical failure target folder does not exist
// -------------------------------------------------------------------------
@Test
void copyToTarget_targetFolderDoesNotExist_returnsTargetFileCopyTechnicalFailure()
throws IOException {
Path sourceFile = createSourceFile("source.pdf", "content");
Path nonExistentTargetFolder = targetFolder.resolve("nonexistent-subfolder");
FilesystemTargetFileCopyAdapter adapterWithMissingFolder =
new FilesystemTargetFileCopyAdapter(nonExistentTargetFolder);
TargetFileCopyResult result = adapterWithMissingFolder.copyToTarget(
new SourceDocumentLocator(sourceFile.toAbsolutePath().toString()),
"2026-01-01 - Rechnung.pdf");
assertThat(result).isInstanceOf(TargetFileCopyTechnicalFailure.class);
}
// -------------------------------------------------------------------------
// Cleanup after failure no temp file left
// -------------------------------------------------------------------------
@Test
void copyToTarget_sourceDoesNotExist_noTempFileLeftInTargetFolder() {
String nonExistentSource = sourceFolder.resolve("missing.pdf").toAbsolutePath().toString();
String resolvedFilename = "2026-01-01 - Test.pdf";
adapter.copyToTarget(
new SourceDocumentLocator(nonExistentSource),
resolvedFilename);
// Even though the copy failed, no temp file should remain
Path tempFile = targetFolder.resolve(resolvedFilename + ".tmp");
assertThat(tempFile).doesNotExist();
}
// -------------------------------------------------------------------------
// TargetFileCopyTechnicalFailure semantics
// -------------------------------------------------------------------------
@Test
void copyToTarget_failure_messageIsNonNull() {
String nonExistentSource = sourceFolder.resolve("ghost.pdf").toAbsolutePath().toString();
TargetFileCopyTechnicalFailure failure = (TargetFileCopyTechnicalFailure)
adapter.copyToTarget(
new SourceDocumentLocator(nonExistentSource),
"2026-01-01 - Test.pdf");
assertThat(failure.errorMessage()).isNotNull();
}
// -------------------------------------------------------------------------
// Null guards
// -------------------------------------------------------------------------
@Test
void copyToTarget_rejectsNullSourceLocator() throws IOException {
assertThatNullPointerException()
.isThrownBy(() -> adapter.copyToTarget(null, "2026-01-01 - Test.pdf"));
}
@Test
void copyToTarget_rejectsNullResolvedFilename() throws IOException {
Path sourceFile = createSourceFile("source.pdf", "content");
assertThatNullPointerException()
.isThrownBy(() -> adapter.copyToTarget(
new SourceDocumentLocator(sourceFile.toAbsolutePath().toString()),
null));
}
@Test
void constructor_rejectsNullTargetFolderPath() {
assertThatNullPointerException()
.isThrownBy(() -> new FilesystemTargetFileCopyAdapter(null));
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private Path createSourceFile(String filename, String content) throws IOException {
Path file = sourceFolder.resolve(filename);
Files.writeString(file, content);
return file;
}
}

View File

@@ -0,0 +1,259 @@
package de.gecheckt.pdf.umbenenner.adapter.out.targetfolder;
import de.gecheckt.pdf.umbenenner.application.port.out.ResolvedTargetFilename;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFilenameResolutionResult;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFolderTechnicalFailure;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
/**
* Tests for {@link FilesystemTargetFolderAdapter}.
* <p>
* Covers duplicate resolution (no conflict, single conflict, multiple conflicts),
* suffix placement, rollback deletion, and error handling.
*/
class FilesystemTargetFolderAdapterTest {
@TempDir
Path targetFolder;
private FilesystemTargetFolderAdapter adapter;
@BeforeEach
void setUp() {
adapter = new FilesystemTargetFolderAdapter(targetFolder);
}
// -------------------------------------------------------------------------
// getTargetFolderLocator
// -------------------------------------------------------------------------
@Test
void getTargetFolderLocator_returnsAbsolutePath() {
String locator = adapter.getTargetFolderLocator();
assertThat(locator).isEqualTo(targetFolder.toAbsolutePath().toString());
}
@Test
void getTargetFolderLocator_isNeverNullOrBlank() {
assertThat(adapter.getTargetFolderLocator()).isNotNull().isNotBlank();
}
// -------------------------------------------------------------------------
// resolveUniqueFilename no conflict
// -------------------------------------------------------------------------
@Test
void resolveUniqueFilename_noConflict_returnsBaseName() {
String baseName = "2026-01-15 - Rechnung.pdf";
TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName);
assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
assertThat(((ResolvedTargetFilename) result).resolvedFilename()).isEqualTo(baseName);
}
// -------------------------------------------------------------------------
// resolveUniqueFilename collision with base name
// -------------------------------------------------------------------------
@Test
void resolveUniqueFilename_baseNameTaken_returnsSuffixOne() throws IOException {
String baseName = "2026-01-15 - Rechnung.pdf";
Files.createFile(targetFolder.resolve(baseName));
TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName);
assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
assertThat(((ResolvedTargetFilename) result).resolvedFilename())
.isEqualTo("2026-01-15 - Rechnung(1).pdf");
}
@Test
void resolveUniqueFilename_baseAndOneTaken_returnsSuffixTwo() throws IOException {
String baseName = "2026-01-15 - Rechnung.pdf";
Files.createFile(targetFolder.resolve(baseName));
Files.createFile(targetFolder.resolve("2026-01-15 - Rechnung(1).pdf"));
TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName);
assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
assertThat(((ResolvedTargetFilename) result).resolvedFilename())
.isEqualTo("2026-01-15 - Rechnung(2).pdf");
}
@Test
void resolveUniqueFilename_multipleTaken_returnsFirstFree() throws IOException {
String baseName = "2026-03-31 - Stromabrechnung.pdf";
// Create base + (1), (2), (3)
Files.createFile(targetFolder.resolve(baseName));
Files.createFile(targetFolder.resolve("2026-03-31 - Stromabrechnung(1).pdf"));
Files.createFile(targetFolder.resolve("2026-03-31 - Stromabrechnung(2).pdf"));
Files.createFile(targetFolder.resolve("2026-03-31 - Stromabrechnung(3).pdf"));
TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName);
assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
assertThat(((ResolvedTargetFilename) result).resolvedFilename())
.isEqualTo("2026-03-31 - Stromabrechnung(4).pdf");
}
// -------------------------------------------------------------------------
// Suffix placement: immediately before .pdf
// -------------------------------------------------------------------------
@Test
void resolveUniqueFilename_suffixPlacedImmediatelyBeforePdf() throws IOException {
String baseName = "2026-04-07 - Bescheid.pdf";
Files.createFile(targetFolder.resolve(baseName));
TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName);
assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
String resolved = ((ResolvedTargetFilename) result).resolvedFilename();
// Must end with "(1).pdf", not ".pdf(1)"
assertThat(resolved).endsWith("(1).pdf");
assertThat(resolved).doesNotContain(".pdf(");
}
// -------------------------------------------------------------------------
// Suffix does not count against 20-char base title
// -------------------------------------------------------------------------
@Test
void resolveUniqueFilename_20CharTitle_suffixDoesNotViolateTitleLimit() throws IOException {
// Base title has exactly 20 chars; with (1) suffix the title exceeds 20, but that is expected
String title = "A".repeat(20); // 20-char title
String baseName = "2026-01-01 - " + title + ".pdf";
Files.createFile(targetFolder.resolve(baseName));
TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(baseName);
assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
String resolved = ((ResolvedTargetFilename) result).resolvedFilename();
// The resolved filename must contain (1) even though overall length > 20 chars
assertThat(resolved).contains("(1)");
}
// -------------------------------------------------------------------------
// resolveUniqueFilename base name without .pdf extension
// -------------------------------------------------------------------------
@Test
void resolveUniqueFilename_baseNameWithoutPdfExtension_whenConflict_returnsFailure()
throws IOException {
// When there is no conflict (file does not exist), the adapter returns the name as-is
// because it only checks the extension when it needs to insert a suffix.
String nameWithoutExt = "2026-01-15 - Rechnung";
// Create a file with that name (no extension) to trigger conflict handling
Files.createFile(targetFolder.resolve(nameWithoutExt));
TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(nameWithoutExt);
// Without .pdf extension, suffix insertion fails
assertThat(result).isInstanceOf(TargetFolderTechnicalFailure.class);
}
// -------------------------------------------------------------------------
// resolveUniqueFilename no conflict, name without .pdf (edge: no conflict → ok)
// -------------------------------------------------------------------------
@Test
void resolveUniqueFilename_baseNameWithoutPdfExtension_whenNoConflict_returnsIt() {
// If the name does not exist, the adapter returns it without checking the extension
String nameWithoutExt = "2026-01-15 - Rechnung";
TargetFilenameResolutionResult result = adapter.resolveUniqueFilename(nameWithoutExt);
assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
assertThat(((ResolvedTargetFilename) result).resolvedFilename()).isEqualTo(nameWithoutExt);
}
// -------------------------------------------------------------------------
// resolveUniqueFilename null guard
// -------------------------------------------------------------------------
@Test
void resolveUniqueFilename_rejectsNullBaseName() {
assertThatNullPointerException()
.isThrownBy(() -> adapter.resolveUniqueFilename(null));
}
// -------------------------------------------------------------------------
// tryDeleteTargetFile file exists, gets deleted
// -------------------------------------------------------------------------
@Test
void tryDeleteTargetFile_fileExists_deletesFile() throws IOException {
String filename = "2026-01-15 - Rechnung.pdf";
Files.createFile(targetFolder.resolve(filename));
assertThat(targetFolder.resolve(filename)).exists();
adapter.tryDeleteTargetFile(filename);
assertThat(targetFolder.resolve(filename)).doesNotExist();
}
// -------------------------------------------------------------------------
// tryDeleteTargetFile file does not exist, no error
// -------------------------------------------------------------------------
@Test
void tryDeleteTargetFile_fileDoesNotExist_doesNotThrow() {
// Must not throw even if the file is absent
adapter.tryDeleteTargetFile("nonexistent.pdf");
}
// -------------------------------------------------------------------------
// tryDeleteTargetFile null guard
// -------------------------------------------------------------------------
@Test
void tryDeleteTargetFile_rejectsNullFilename() {
assertThatNullPointerException()
.isThrownBy(() -> adapter.tryDeleteTargetFile(null));
}
// -------------------------------------------------------------------------
// resolveUniqueFilename non-existent target folder
// -------------------------------------------------------------------------
@Test
void resolveUniqueFilename_nonExistentTargetFolder_returnsFailure() {
Path nonExistentFolder = targetFolder.resolve("does-not-exist");
FilesystemTargetFolderAdapter adapterWithMissingFolder =
new FilesystemTargetFolderAdapter(nonExistentFolder);
String baseName = "2026-01-01 - Test.pdf";
// Files.exists() on a file in a non-existent folder does not throw;
// it simply returns false, so the adapter returns the base name.
// This is consistent behaviour: no folder access error when just checking existence.
TargetFilenameResolutionResult result = adapterWithMissingFolder.resolveUniqueFilename(baseName);
// Adapter returns the base name since no conflict is detected for a non-existent folder
assertThat(result).isInstanceOf(ResolvedTargetFilename.class);
assertThat(((ResolvedTargetFilename) result).resolvedFilename()).isEqualTo(baseName);
}
// -------------------------------------------------------------------------
// Construction null guard
// -------------------------------------------------------------------------
@Test
void constructor_rejectsNullTargetFolderPath() {
assertThatNullPointerException()
.isThrownBy(() -> new FilesystemTargetFolderAdapter(null));
}
}

View File

@@ -19,6 +19,12 @@
<version>${project.version}</version> <version>${project.version}</version>
</dependency> </dependency>
<!-- JSON parsing for AI response parsing -->
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
</dependency>
<!-- Test dependencies --> <!-- Test dependencies -->
<dependency> <dependency>
<groupId>org.junit.jupiter</groupId> <groupId>org.junit.jupiter</groupId>

View File

@@ -37,7 +37,11 @@ import java.util.Objects;
* <li>{@link #updatedAt()} — timestamp of the most recent update to this master record.</li> * <li>{@link #updatedAt()} — timestamp of the most recent update to this master record.</li>
* </ul> * </ul>
* <p> * <p>
* <strong>Not yet included:</strong> target path, target file name, AI-related fields. * <strong>Target location fields:</strong> {@link #lastTargetPath()} and
* {@link #lastTargetFileName()} are populated only after the document reaches
* {@link de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus#SUCCESS}. Both
* fields are {@code null} for documents that have not yet been successfully copied
* to the target folder.
* *
* @param fingerprint content-based identity; never null * @param fingerprint content-based identity; never null
* @param lastKnownSourceLocator opaque locator to the physical source file; never null * @param lastKnownSourceLocator opaque locator to the physical source file; never null
@@ -48,6 +52,10 @@ import java.util.Objects;
* @param lastSuccessInstant timestamp of the successful processing, or {@code null} * @param lastSuccessInstant timestamp of the successful processing, or {@code null}
* @param createdAt timestamp when this record was first created; never null * @param createdAt timestamp when this record was first created; never null
* @param updatedAt timestamp of the most recent update; never null * @param updatedAt timestamp of the most recent update; never null
* @param lastTargetPath opaque locator of the target folder where the last
* successful copy was written, or {@code null}
* @param lastTargetFileName filename of the last successfully written target copy
* (including any duplicate suffix), or {@code null}
*/ */
public record DocumentRecord( public record DocumentRecord(
DocumentFingerprint fingerprint, DocumentFingerprint fingerprint,
@@ -58,7 +66,9 @@ public record DocumentRecord(
Instant lastFailureInstant, Instant lastFailureInstant,
Instant lastSuccessInstant, Instant lastSuccessInstant,
Instant createdAt, Instant createdAt,
Instant updatedAt) { Instant updatedAt,
String lastTargetPath,
String lastTargetFileName) {
/** /**
* Compact constructor validating mandatory non-null fields. * Compact constructor validating mandatory non-null fields.

View File

@@ -1,10 +1,12 @@
package de.gecheckt.pdf.umbenenner.application.port.out; package de.gecheckt.pdf.umbenenner.application.port.out;
import de.gecheckt.pdf.umbenenner.domain.model.DateSource;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint; import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus; import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
import de.gecheckt.pdf.umbenenner.domain.model.RunId; import de.gecheckt.pdf.umbenenner.domain.model.RunId;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate;
import java.util.Objects; import java.util.Objects;
/** /**
@@ -40,10 +42,28 @@ import java.util.Objects;
* successful or skip attempts.</li> * successful or skip attempts.</li>
* <li>{@link #retryable()} — {@code true} if the failure is considered retryable in a * <li>{@link #retryable()} — {@code true} if the failure is considered retryable in a
* later run; {@code false} for final failures, successes, and skip attempts.</li> * later run; {@code false} for final failures, successes, and skip attempts.</li>
* <li>{@link #modelName()} — the AI model name used in this attempt; {@code null} if
* no AI call was made (e.g. pre-check failures or skip attempts).</li>
* <li>{@link #promptIdentifier()} — stable identifier of the prompt template used;
* {@code null} if no AI call was made.</li>
* <li>{@link #processedPageCount()} — number of PDF pages processed; {@code null} if
* pages were not extracted (e.g. pre-fingerprint or skip attempts).</li>
* <li>{@link #sentCharacterCount()} — number of characters sent to the AI; {@code null}
* if no AI call was made.</li>
* <li>{@link #aiRawResponse()} — the complete raw AI response body; {@code null} if no
* AI call was made. Stored in SQLite but not written to log files by default.</li>
* <li>{@link #aiReasoning()} — the reasoning extracted from the AI response; {@code null}
* if no valid AI response was obtained.</li>
* <li>{@link #resolvedDate()} — the date resolved for the naming proposal; {@code null}
* if no naming proposal was produced.</li>
* <li>{@link #dateSource()} — the origin of the resolved date; {@code null} if no
* naming proposal was produced.</li>
* <li>{@link #validatedTitle()} — the validated title from the naming proposal;
* {@code null} if no naming proposal was produced.</li>
* <li>{@link #finalTargetFileName()} — the final filename written to the target folder
* (including any duplicate suffix); set only for
* {@link ProcessingStatus#SUCCESS} attempts, {@code null} otherwise.</li>
* </ul> * </ul>
* <p>
* <strong>Not yet included:</strong> model name, prompt identifier, AI raw response,
* AI reasoning, resolved date, date source, final title, final target file name.
* *
* @param fingerprint content-based document identity; never null * @param fingerprint content-based document identity; never null
* @param runId identifier of the batch run; never null * @param runId identifier of the batch run; never null
@@ -54,6 +74,17 @@ import java.util.Objects;
* @param failureClass failure classification, or {@code null} for non-failure statuses * @param failureClass failure classification, or {@code null} for non-failure statuses
* @param failureMessage failure description, or {@code null} for non-failure statuses * @param failureMessage failure description, or {@code null} for non-failure statuses
* @param retryable whether this failure should be retried in a later run * @param retryable whether this failure should be retried in a later run
* @param modelName AI model name, or {@code null} if no AI call was made
* @param promptIdentifier prompt identifier, or {@code null} if no AI call was made
* @param processedPageCount number of PDF pages processed, or {@code null}
* @param sentCharacterCount number of characters sent to AI, or {@code null}
* @param aiRawResponse full raw AI response, or {@code null}
* @param aiReasoning AI reasoning text, or {@code null}
* @param resolvedDate resolved date for naming proposal, or {@code null}
* @param dateSource origin of resolved date, or {@code null}
* @param validatedTitle validated title, or {@code null}
* @param finalTargetFileName filename written to the target folder for SUCCESS attempts,
* or {@code null}
*/ */
public record ProcessingAttempt( public record ProcessingAttempt(
DocumentFingerprint fingerprint, DocumentFingerprint fingerprint,
@@ -64,7 +95,19 @@ public record ProcessingAttempt(
ProcessingStatus status, ProcessingStatus status,
String failureClass, String failureClass,
String failureMessage, String failureMessage,
boolean retryable) { boolean retryable,
// AI traceability fields (null for non-AI attempts)
String modelName,
String promptIdentifier,
Integer processedPageCount,
Integer sentCharacterCount,
String aiRawResponse,
String aiReasoning,
LocalDate resolvedDate,
DateSource dateSource,
String validatedTitle,
// Target copy traceability (null for non-SUCCESS attempts)
String finalTargetFileName) {
/** /**
* Compact constructor validating mandatory non-null fields and numeric constraints. * Compact constructor validating mandatory non-null fields and numeric constraints.
@@ -83,4 +126,37 @@ public record ProcessingAttempt(
Objects.requireNonNull(endedAt, "endedAt must not be null"); Objects.requireNonNull(endedAt, "endedAt must not be null");
Objects.requireNonNull(status, "status must not be null"); Objects.requireNonNull(status, "status must not be null");
} }
/**
* Creates a {@link ProcessingAttempt} with no AI traceability fields set.
* <p>
* Convenience factory for pre-check failures, skip events, and any attempt
* that does not involve an AI call.
*
* @param fingerprint document identity; must not be null
* @param runId batch run identifier; must not be null
* @param attemptNumber monotonic attempt number; must be &gt;= 1
* @param startedAt start instant; must not be null
* @param endedAt end instant; must not be null
* @param status outcome status; must not be null
* @param failureClass failure class name, or {@code null}
* @param failureMessage failure description, or {@code null}
* @param retryable whether retryable in a later run
* @return a new attempt with all AI fields set to {@code null}
*/
public static ProcessingAttempt withoutAiFields(
DocumentFingerprint fingerprint,
RunId runId,
int attemptNumber,
Instant startedAt,
Instant endedAt,
ProcessingStatus status,
String failureClass,
String failureMessage,
boolean retryable) {
return new ProcessingAttempt(
fingerprint, runId, attemptNumber, startedAt, endedAt,
status, failureClass, failureMessage, retryable,
null, null, null, null, null, null, null, null, null, null);
}
} }

View File

@@ -1,6 +1,7 @@
package de.gecheckt.pdf.umbenenner.application.port.out; package de.gecheckt.pdf.umbenenner.application.port.out;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint; import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
import java.util.List; import java.util.List;
@@ -66,4 +67,25 @@ public interface ProcessingAttemptRepository {
* @throws DocumentPersistenceException if the query fails due to a technical error * @throws DocumentPersistenceException if the query fails due to a technical error
*/ */
List<ProcessingAttempt> findAllByFingerprint(DocumentFingerprint fingerprint); List<ProcessingAttempt> findAllByFingerprint(DocumentFingerprint fingerprint);
/**
* Returns the most recent attempt with status {@link ProcessingStatus#PROPOSAL_READY}
* for the given fingerprint, or {@code null} if no such attempt exists.
* <p>
* <strong>Leading source for subsequent processing stages:</strong>
* The most recent {@code PROPOSAL_READY} attempt is the authoritative source for
* the validated naming proposal (resolved date, date source, validated title, and
* AI reasoning) consumed by subsequent stages. The document master record does not
* carry redundant proposal data; this method is the only correct way to retrieve it.
* <p>
* If the overall document status is {@code PROPOSAL_READY} but this method returns
* {@code null}, or if the returned attempt is missing mandatory proposal fields, the
* state is considered an inconsistent persistence state and must be treated as a
* document-level technical error — not silently healed.
*
* @param fingerprint the document identity; must not be null
* @return the most recent {@code PROPOSAL_READY} attempt, or {@code null} if none exists
* @throws DocumentPersistenceException if the query fails due to a technical error
*/
ProcessingAttempt findLatestProposalReadyAttempt(DocumentFingerprint fingerprint);
} }

View File

@@ -0,0 +1,26 @@
package de.gecheckt.pdf.umbenenner.application.port.out;
import java.util.Objects;
/**
* Successful outcome of {@link TargetFolderPort#resolveUniqueFilename(String)}.
* <p>
* Carries the first available filename in the target folder. The filename includes
* the {@code .pdf} extension and, if needed, a numeric duplicate-avoidance suffix
* inserted directly before {@code .pdf} (e.g., {@code "2024-01-15 - Rechnung(1).pdf"}).
*
* @param resolvedFilename the available filename including extension; never null or blank
*/
public record ResolvedTargetFilename(String resolvedFilename) implements TargetFilenameResolutionResult {
/**
* @throws NullPointerException if {@code resolvedFilename} is null
* @throws IllegalArgumentException if {@code resolvedFilename} is blank
*/
public ResolvedTargetFilename {
Objects.requireNonNull(resolvedFilename, "resolvedFilename must not be null");
if (resolvedFilename.isBlank()) {
throw new IllegalArgumentException("resolvedFilename must not be blank");
}
}
}

View File

@@ -0,0 +1,45 @@
package de.gecheckt.pdf.umbenenner.application.port.out;
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator;
/**
* Outbound port for copying a source PDF to the target folder.
* <p>
* The physical copy is the final step in the successful document processing path.
* Copying is performed via a temporary file in the target context with a subsequent
* atomic move/rename to the final target filename, minimising the risk of incomplete
* target files being visible.
*
* <h2>Source integrity</h2>
* <p>
* The source file identified by the {@link SourceDocumentLocator} is <strong>never</strong>
* modified, moved, or deleted by this port. Only a copy is written.
*
* <h2>No immediate retry</h2>
* <p>
* This port performs exactly one copy attempt per invocation. No automatic retry within
* the same call is performed; retry decisions belong to higher-level orchestration.
*
* <h2>Architecture boundary</h2>
* <p>
* No {@code Path}, {@code File}, or NIO types appear in this interface.
*/
public interface TargetFileCopyPort {
/**
* Copies the source document to the target folder under the given resolved filename.
* <p>
* The implementation writes to a temporary file first and then performs a
* move/rename to the final {@code resolvedFilename}. If the move fails, a
* best-effort cleanup of the temporary file is attempted before returning the
* failure result.
*
* @param sourceLocator opaque locator identifying the source file; must not be null
* @param resolvedFilename the final filename (not full path) to write in the target
* folder; must not be null or blank; must have been obtained
* from {@link TargetFolderPort#resolveUniqueFilename(String)}
* @return {@link TargetFileCopySuccess} if the copy completed successfully, or
* {@link TargetFileCopyTechnicalFailure} if any step failed
*/
TargetFileCopyResult copyToTarget(SourceDocumentLocator sourceLocator, String resolvedFilename);
}

View File

@@ -0,0 +1,14 @@
package de.gecheckt.pdf.umbenenner.application.port.out;
/**
* Sealed result type for {@link TargetFileCopyPort#copyToTarget}.
* <p>
* Permits exactly two outcomes:
* <ul>
* <li>{@link TargetFileCopySuccess} — the source was successfully copied to the target.</li>
* <li>{@link TargetFileCopyTechnicalFailure} — a technical failure occurred during copying.</li>
* </ul>
*/
public sealed interface TargetFileCopyResult
permits TargetFileCopySuccess, TargetFileCopyTechnicalFailure {
}

View File

@@ -0,0 +1,10 @@
package de.gecheckt.pdf.umbenenner.application.port.out;
/**
* Successful outcome of {@link TargetFileCopyPort#copyToTarget}.
* <p>
* Indicates that the source file was successfully copied to the target folder and the
* final move/rename completed. The target file is now visible under the resolved filename.
*/
public record TargetFileCopySuccess() implements TargetFileCopyResult {
}

View File

@@ -0,0 +1,30 @@
package de.gecheckt.pdf.umbenenner.application.port.out;
import java.util.Objects;
/**
* Technical failure outcome of {@link TargetFileCopyPort#copyToTarget}.
* <p>
* Indicates that copying the source file to the target folder failed. The failure is
* always treated as a transient, retryable document-level technical error.
* <p>
* The {@code targetFileCleanedUp} flag records whether a best-effort cleanup of any
* partially written temporary target file was successful. A value of {@code false}
* means a stale temporary file may remain in the target folder; a value of {@code true}
* means cleanup succeeded (or no temporary file had been created at all).
*
* @param errorMessage human-readable description of the failure; never null
* @param targetFileCleanedUp {@code true} if cleanup of any temporary file succeeded;
* {@code false} if cleanup failed or was not attempted
*/
public record TargetFileCopyTechnicalFailure(
String errorMessage,
boolean targetFileCleanedUp) implements TargetFileCopyResult {
/**
* @throws NullPointerException if {@code errorMessage} is null
*/
public TargetFileCopyTechnicalFailure {
Objects.requireNonNull(errorMessage, "errorMessage must not be null");
}
}

View File

@@ -0,0 +1,14 @@
package de.gecheckt.pdf.umbenenner.application.port.out;
/**
* Sealed result type for {@link TargetFolderPort#resolveUniqueFilename(String)}.
* <p>
* Permits exactly two outcomes:
* <ul>
* <li>{@link ResolvedTargetFilename} — the first available unique filename was determined.</li>
* <li>{@link TargetFolderTechnicalFailure} — the target folder could not be accessed.</li>
* </ul>
*/
public sealed interface TargetFilenameResolutionResult
permits ResolvedTargetFilename, TargetFolderTechnicalFailure {
}

View File

@@ -0,0 +1,71 @@
package de.gecheckt.pdf.umbenenner.application.port.out;
/**
* Outbound port for target folder access: duplicate resolution and best-effort cleanup.
* <p>
* The target folder is the directory where the renamed PDF copy is written. This port
* encapsulates all target-folder concerns so that the application layer never handles
* filesystem types ({@code Path}, {@code File}) directly.
*
* <h2>Duplicate resolution</h2>
* <p>
* When the base filename is already taken in the target folder, the port determines
* the first available name by appending a numeric suffix directly before {@code .pdf}:
* <pre>
* 2024-01-15 - Rechnung.pdf
* 2024-01-15 - Rechnung(1).pdf
* 2024-01-15 - Rechnung(2).pdf
* ...
* </pre>
* The base filename must already include the {@code .pdf} extension. The suffix is
* purely a technical collision-avoidance mechanism and introduces no new fachliche
* title interpretation.
*
* <h2>Architecture boundary</h2>
* <p>
* No {@code Path}, {@code File}, or NIO types appear in this interface. The concrete
* adapter implementation translates the opaque folder locator string to actual
* filesystem operations.
*/
public interface TargetFolderPort {
/**
* Returns an opaque string that identifies the target folder managed by this port.
* <p>
* The application layer treats this as an opaque locator and stores it in the
* document master record ({@code lastTargetPath}) for traceability. It must not
* be interpreted by the application layer.
*
* @return a non-null, non-blank string identifying the target folder
*/
String getTargetFolderLocator();
/**
* Resolves the first available unique filename in the target folder for the given base name.
* <p>
* If the base name is not yet taken, it is returned unchanged. Otherwise the method
* appends {@code (1)}, {@code (2)}, etc. directly before {@code .pdf} until a free
* name is found.
* <p>
* The returned filename contains only the file name, not the full path. It is safe
* to use as the {@code resolvedFilename} parameter of
* {@link TargetFileCopyPort#copyToTarget(de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator, String)}.
*
* @param baseName the desired filename including the {@code .pdf} extension;
* must not be null or blank
* @return a {@link ResolvedTargetFilename} with the first available name, or a
* {@link TargetFolderTechnicalFailure} if the target folder is not accessible
*/
TargetFilenameResolutionResult resolveUniqueFilename(String baseName);
/**
* Best-effort attempt to delete a file previously written to the target folder.
* <p>
* Intended for rollback after a successful target copy when subsequent persistence
* fails. This method must not throw; if deletion fails for any reason, the failure
* is silently ignored.
*
* @param resolvedFilename the filename (not full path) to delete; must not be null
*/
void tryDeleteTargetFile(String resolvedFilename);
}

View File

@@ -0,0 +1,22 @@
package de.gecheckt.pdf.umbenenner.application.port.out;
import java.util.Objects;
/**
* Technical failure outcome of {@link TargetFolderPort#resolveUniqueFilename(String)}.
* <p>
* Indicates that the target folder could not be accessed when attempting to determine
* a unique filename. This is a transient infrastructure error; the calling use case
* should treat it as a retryable document-level technical error.
*
* @param errorMessage human-readable description of the failure; never null
*/
public record TargetFolderTechnicalFailure(String errorMessage) implements TargetFilenameResolutionResult {
/**
* @throws NullPointerException if {@code errorMessage} is null
*/
public TargetFolderTechnicalFailure {
Objects.requireNonNull(errorMessage, "errorMessage must not be null");
}
}

View File

@@ -0,0 +1,242 @@
package de.gecheckt.pdf.umbenenner.application.service;
import java.util.Objects;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationPort;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationSuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationTechnicalFailure;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingSuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptPort;
import de.gecheckt.pdf.umbenenner.domain.model.AiAttemptContext;
import de.gecheckt.pdf.umbenenner.domain.model.AiFunctionalFailure;
import de.gecheckt.pdf.umbenenner.domain.model.AiRequestRepresentation;
import de.gecheckt.pdf.umbenenner.domain.model.AiResponseParsingFailure;
import de.gecheckt.pdf.umbenenner.domain.model.AiResponseParsingSuccess;
import de.gecheckt.pdf.umbenenner.domain.model.AiTechnicalFailure;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentProcessingOutcome;
import de.gecheckt.pdf.umbenenner.domain.model.NamingProposal;
import de.gecheckt.pdf.umbenenner.domain.model.NamingProposalReady;
import de.gecheckt.pdf.umbenenner.domain.model.ParsedAiResponse;
import de.gecheckt.pdf.umbenenner.domain.model.PreCheckPassed;
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate;
/**
* Orchestrates the complete AI naming pipeline for a single document.
* <p>
* This service is called after pre-checks have passed (i.e. after extraction
* and content quality checks) and performs exactly these steps in order:
* <ol>
* <li>Load the external prompt template via {@link PromptPort}.</li>
* <li>Limit the extracted document text to the configured maximum character count.</li>
* <li>Compose a deterministic AI request from the prompt and limited text.</li>
* <li>Invoke the AI service via {@link AiInvocationPort}.</li>
* <li>Parse the raw AI response for structural correctness.</li>
* <li>Validate the parsed response for semantic correctness (title, date).</li>
* <li>Return a typed {@link DocumentProcessingOutcome} encoding success or failure.</li>
* </ol>
*
* <h2>Outcome classification</h2>
* <ul>
* <li>{@link NamingProposalReady} — AI responded with a structurally and semantically
* valid naming proposal; document status will become {@code PROPOSAL_READY}.</li>
* <li>{@link AiTechnicalFailure} — transient infrastructure problem (prompt load failure,
* HTTP error, timeout, connection problem, or unparseable JSON response); transient
* error counter is incremented and the document is retryable in a later run.</li>
* <li>{@link AiFunctionalFailure} — the AI responded successfully but the content
* fails deterministic validation (title too long, prohibited characters, generic
* placeholder, unparseable date); content error counter is incremented and the
* one-retry rule applies.</li>
* </ul>
*
* <h2>AI traceability</h2>
* <p>
* Every returned outcome carries an {@link AiAttemptContext} with the model name,
* prompt identifier, page count, sent character count, and raw response (null on
* connection failure). This context is persisted verbatim in the processing attempt
* history by the coordinator.
*
* <h2>Thread safety</h2>
* <p>
* This service is stateless with respect to individual documents. It is safe to
* reuse a single instance across documents within the same batch run, provided the
* injected dependencies are thread-safe.
*/
public class AiNamingService {
private final AiInvocationPort aiInvocationPort;
private final PromptPort promptPort;
private final AiResponseValidator aiResponseValidator;
private final String modelName;
private final int maxTextCharacters;
/**
* Creates the AI naming service with all required dependencies.
*
* @param aiInvocationPort port for invoking the AI over HTTP; must not be null
* @param promptPort port for loading the external prompt template; must not be null
* @param aiResponseValidator semantic validator for parsed AI responses; must not be null
* @param modelName the AI model name to record in attempt history; must not be null
* @param maxTextCharacters the maximum number of document-text characters to send;
* must be &gt;= 1
* @throws NullPointerException if any reference parameter is null
* @throws IllegalArgumentException if {@code maxTextCharacters} is less than 1
*/
public AiNamingService(
AiInvocationPort aiInvocationPort,
PromptPort promptPort,
AiResponseValidator aiResponseValidator,
String modelName,
int maxTextCharacters) {
this.aiInvocationPort = Objects.requireNonNull(aiInvocationPort, "aiInvocationPort must not be null");
this.promptPort = Objects.requireNonNull(promptPort, "promptPort must not be null");
this.aiResponseValidator = Objects.requireNonNull(aiResponseValidator, "aiResponseValidator must not be null");
this.modelName = Objects.requireNonNull(modelName, "modelName must not be null");
if (maxTextCharacters < 1) {
throw new IllegalArgumentException(
"maxTextCharacters must be >= 1, but was: " + maxTextCharacters);
}
this.maxTextCharacters = maxTextCharacters;
}
/**
* Runs the AI naming pipeline for a document that passed all pre-checks.
* <p>
* The extraction result embedded in {@code preCheckPassed} supplies the
* document text and page count needed for the AI request. The candidate is
* carried through for correct outcome construction (correlation, logging).
*
* @param preCheckPassed the pre-check result carrying the candidate and extraction;
* must not be null
* @return a {@link DocumentProcessingOutcome} encoding the AI pipeline result;
* one of {@link NamingProposalReady}, {@link AiTechnicalFailure}, or
* {@link AiFunctionalFailure}; never null
* @throws NullPointerException if {@code preCheckPassed} is null
*/
public DocumentProcessingOutcome invoke(PreCheckPassed preCheckPassed) {
Objects.requireNonNull(preCheckPassed, "preCheckPassed must not be null");
SourceDocumentCandidate candidate = preCheckPassed.candidate();
int pageCount = preCheckPassed.extraction().pageCount().value();
String rawText = preCheckPassed.extraction().extractedText();
// Step 1: Load the external prompt template
return switch (promptPort.loadPrompt()) {
case PromptLoadingFailure promptFailure ->
// Prompt is unavailable — transient infrastructure failure; retryable
new AiTechnicalFailure(
candidate,
"Prompt loading failed [" + promptFailure.failureReason() + "]: "
+ promptFailure.failureMessage(),
null,
new AiAttemptContext(modelName, "prompt-load-failed", pageCount, 0, null));
case PromptLoadingSuccess promptSuccess ->
invokeWithPrompt(candidate, rawText, pageCount, promptSuccess);
};
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
/**
* Continues the AI pipeline after the prompt has been loaded successfully.
*/
private DocumentProcessingOutcome invokeWithPrompt(
SourceDocumentCandidate candidate,
String rawText,
int pageCount,
PromptLoadingSuccess promptSuccess) {
String promptIdentifier = promptSuccess.promptIdentifier().identifier();
String promptContent = promptSuccess.promptContent();
// Step 2: Limit the document text to the configured maximum
String limitedText = DocumentTextLimiter.limit(rawText, maxTextCharacters);
int sentCharacterCount = limitedText.length();
// Step 3: Compose a deterministic AI request
AiRequestRepresentation request = AiRequestComposer.compose(
promptSuccess.promptIdentifier(),
promptContent,
limitedText);
// Step 4: Invoke the AI service
return switch (aiInvocationPort.invoke(request)) {
case AiInvocationTechnicalFailure invocationFailure ->
// Transient infrastructure failure: timeout, network error, etc.
new AiTechnicalFailure(
candidate,
"AI invocation failed [" + invocationFailure.failureReason() + "]: "
+ invocationFailure.failureMessage(),
null,
new AiAttemptContext(
modelName, promptIdentifier, pageCount, sentCharacterCount, null));
case AiInvocationSuccess invocationSuccess ->
processSuccessfulInvocation(
candidate, pageCount, sentCharacterCount, promptIdentifier,
invocationSuccess);
};
}
/**
* Processes a technically successful AI invocation: parses and validates the response.
*/
private DocumentProcessingOutcome processSuccessfulInvocation(
SourceDocumentCandidate candidate,
int pageCount,
int sentCharacterCount,
String promptIdentifier,
AiInvocationSuccess invocationSuccess) {
String rawResponseBody = invocationSuccess.rawResponse().content();
// Step 5: Parse the raw response for structural correctness
return switch (AiResponseParser.parse(invocationSuccess.rawResponse())) {
case AiResponseParsingFailure parsingFailure ->
// Unparseable JSON or structurally invalid response: transient technical error
new AiTechnicalFailure(
candidate,
"AI response could not be parsed [" + parsingFailure.failureReason() + "]: "
+ parsingFailure.failureMessage(),
null,
new AiAttemptContext(
modelName, promptIdentifier, pageCount, sentCharacterCount,
rawResponseBody));
case AiResponseParsingSuccess parsingSuccess ->
// Step 6: Validate semantics (title rules, date format)
validateAndBuildOutcome(
candidate, pageCount, sentCharacterCount, promptIdentifier,
rawResponseBody, parsingSuccess.response());
};
}
/**
* Validates the parsed AI response and builds the final outcome.
*/
private DocumentProcessingOutcome validateAndBuildOutcome(
SourceDocumentCandidate candidate,
int pageCount,
int sentCharacterCount,
String promptIdentifier,
String rawResponseBody,
ParsedAiResponse parsedResponse) {
AiAttemptContext aiContext = new AiAttemptContext(
modelName, promptIdentifier, pageCount, sentCharacterCount, rawResponseBody);
return switch (aiResponseValidator.validate(parsedResponse)) {
case AiResponseValidator.AiValidationResult.Invalid invalid ->
// Deterministic semantic failure: bad title, bad date, generic placeholder
new AiFunctionalFailure(candidate, invalid.errorMessage(), aiContext);
case AiResponseValidator.AiValidationResult.Valid valid -> {
NamingProposal proposal = valid.proposal();
yield new NamingProposalReady(candidate, proposal, aiContext);
}
};
}
}

View File

@@ -0,0 +1,107 @@
package de.gecheckt.pdf.umbenenner.application.service;
import java.util.Objects;
import org.json.JSONException;
import org.json.JSONObject;
import de.gecheckt.pdf.umbenenner.domain.model.AiRawResponse;
import de.gecheckt.pdf.umbenenner.domain.model.AiResponseParsingFailure;
import de.gecheckt.pdf.umbenenner.domain.model.AiResponseParsingResult;
import de.gecheckt.pdf.umbenenner.domain.model.AiResponseParsingSuccess;
import de.gecheckt.pdf.umbenenner.domain.model.ParsedAiResponse;
/**
* Parses the raw AI response body into a structurally validated {@link ParsedAiResponse}.
* <p>
* This parser enforces the technical contract: the AI must respond with exactly one
* parseable JSON object containing the mandatory fields {@code title} and {@code reasoning},
* and an optional {@code date} field. Any extra free-text outside the JSON object makes
* the response technically invalid.
*
* <h2>Parsing rules</h2>
* <ul>
* <li>The response body must be a valid JSON object (no arrays, no primitives).</li>
* <li>{@code title} must be present and non-empty.</li>
* <li>{@code reasoning} must be present (may be empty in degenerate cases, but must exist).</li>
* <li>{@code date} is optional; if absent the field is modelled as an empty Optional.</li>
* <li>Additional JSON fields are tolerated and silently ignored.</li>
* <li>Any free-text outside the outermost JSON object makes the response technically
* unacceptable; this is detected by attempting to parse the trimmed body directly
* as a JSON object and rejecting inputs that are not pure JSON objects.</li>
* </ul>
*
* <h2>Architecture boundary</h2>
* <p>
* Only structural parsing is performed here. Semantic validation (title length,
* special characters, date format, generic placeholder detection) is the responsibility
* of {@link AiResponseValidator}.
*/
public final class AiResponseParser {
private AiResponseParser() {
// Static utility no instances
}
/**
* Attempts to parse {@code rawResponse} into a {@link ParsedAiResponse}.
* <p>
* Returns {@link AiResponseParsingSuccess} if the response body is a valid JSON object
* containing the mandatory fields. Returns {@link AiResponseParsingFailure} for any
* structural problem: non-JSON content, JSON that is not an object, missing mandatory
* fields, or extra free-text surrounding the JSON object.
*
* @param rawResponse the raw AI response body; must not be null
* @return a parsing result indicating success or failure; never null
* @throws NullPointerException if {@code rawResponse} is null
*/
public static AiResponseParsingResult parse(AiRawResponse rawResponse) {
Objects.requireNonNull(rawResponse, "rawResponse must not be null");
String body = rawResponse.content();
if (body == null || body.isBlank()) {
return new AiResponseParsingFailure("EMPTY_RESPONSE", "AI response body is empty or blank");
}
String trimmed = body.trim();
// Reject if the body does not start with '{' and end with '}' (i.e., not a pure JSON object).
// This catches responses that embed a JSON object within surrounding prose.
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) {
return new AiResponseParsingFailure(
"NOT_JSON_OBJECT",
"AI response is not a pure JSON object (contains extra text or is not an object)");
}
JSONObject json;
try {
json = new JSONObject(trimmed);
} catch (JSONException e) {
return new AiResponseParsingFailure("INVALID_JSON", "AI response is not valid JSON: " + e.getMessage());
}
// Validate mandatory field: title
if (!json.has("title") || json.isNull("title")) {
return new AiResponseParsingFailure("MISSING_TITLE", "AI response missing mandatory field 'title'");
}
String title = json.getString("title");
if (title.isBlank()) {
return new AiResponseParsingFailure("BLANK_TITLE", "AI response field 'title' is blank");
}
// Validate mandatory field: reasoning
if (!json.has("reasoning") || json.isNull("reasoning")) {
return new AiResponseParsingFailure("MISSING_REASONING", "AI response missing mandatory field 'reasoning'");
}
String reasoning = json.getString("reasoning");
// Optional field: date
String dateString = null;
if (json.has("date") && !json.isNull("date")) {
dateString = json.getString("date");
}
ParsedAiResponse parsed = ParsedAiResponse.of(title, reasoning, dateString);
return new AiResponseParsingSuccess(parsed);
}
}

View File

@@ -0,0 +1,215 @@
package de.gecheckt.pdf.umbenenner.application.service;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.Objects;
import java.util.Set;
import de.gecheckt.pdf.umbenenner.application.port.out.ClockPort;
import de.gecheckt.pdf.umbenenner.domain.model.AiErrorClassification;
import de.gecheckt.pdf.umbenenner.domain.model.DateSource;
import de.gecheckt.pdf.umbenenner.domain.model.NamingProposal;
import de.gecheckt.pdf.umbenenner.domain.model.ParsedAiResponse;
/**
* Validates the semantics of a structurally parsed AI response and produces a
* {@link NamingProposal} or a classified validation error.
*
* <h2>What this validator checks</h2>
* <p>
* All objectively computable rules are enforced here. Rules that depend on linguistic
* judgement (German language, comprehensibility, treatment of proper nouns) are
* delegated to the AI via the prompt contract and are not verified programmatically.
*
* <h3>Title rules (objective)</h3>
* <ul>
* <li>Base title must not exceed 20 characters.</li>
* <li>Title must not contain characters other than letters, digits, and space
* (Umlauts and ß are permitted).</li>
* <li>Title must not be a generic placeholder (e.g., "Dokument", "Datei", "Scan",
* "PDF", "Seite", "Unbekannt").</li>
* </ul>
*
* <h3>Date rules (objective)</h3>
* <ul>
* <li>If the AI provides a {@code date}, it must be interpretable as ISO-8601
* {@code YYYY-MM-DD}. A provided but unparseable date is a
* {@link AiErrorClassification#FUNCTIONAL functional} error.</li>
* <li>If the AI provides no {@code date}, the current date from {@link ClockPort} is
* used as a fallback ({@link DateSource#FALLBACK_CURRENT}).</li>
* </ul>
*
* <h2>Result</h2>
* <ul>
* <li>{@link AiValidationResult.Valid} — semantically sound; contains a ready
* {@link NamingProposal}.</li>
* <li>{@link AiValidationResult.Invalid} — contains an error message and the
* {@link AiErrorClassification} (always {@link AiErrorClassification#FUNCTIONAL}
* for validation failures from this class).</li>
* </ul>
*/
public final class AiResponseValidator {
/**
* Known generic placeholder titles that are not acceptable as document names.
* These are case-insensitive matches.
*/
private static final Set<String> GENERIC_TITLES = Set.of(
"dokument", "datei", "scan", "pdf", "seite", "unbekannt",
"document", "file", "unknown", "page"
);
private final ClockPort clockPort;
/**
* Creates the validator with the given clock for date fallback.
*
* @param clockPort the clock for current-date fallback; must not be null
* @throws NullPointerException if {@code clockPort} is null
*/
public AiResponseValidator(ClockPort clockPort) {
this.clockPort = Objects.requireNonNull(clockPort, "clockPort must not be null");
}
/**
* Validates the parsed AI response and produces a {@link NamingProposal} on success.
*
* @param parsed the structurally parsed AI response; must not be null
* @return a {@link AiValidationResult} indicating validity or the specific failure;
* never null
* @throws NullPointerException if {@code parsed} is null
*/
public AiValidationResult validate(ParsedAiResponse parsed) {
Objects.requireNonNull(parsed, "parsed must not be null");
// --- Title validation ---
String title = parsed.title().trim();
if (title.length() > 20) {
return AiValidationResult.invalid(
"Title exceeds 20 characters (base title): '" + title + "'",
AiErrorClassification.FUNCTIONAL);
}
if (!isAllowedTitleCharacters(title)) {
return AiValidationResult.invalid(
"Title contains disallowed characters (only letters, digits, and spaces are permitted): '"
+ title + "'",
AiErrorClassification.FUNCTIONAL);
}
if (isGenericTitle(title)) {
return AiValidationResult.invalid(
"Title is a generic placeholder and not acceptable: '" + title + "'",
AiErrorClassification.FUNCTIONAL);
}
// --- Date validation / fallback ---
LocalDate resolvedDate;
DateSource dateSource;
if (parsed.dateString().isPresent()) {
String dateStr = parsed.dateString().get();
try {
resolvedDate = LocalDate.parse(dateStr);
dateSource = DateSource.AI_PROVIDED;
} catch (DateTimeParseException e) {
return AiValidationResult.invalid(
"AI-provided date '" + dateStr + "' is not a valid YYYY-MM-DD date: " + e.getMessage(),
AiErrorClassification.FUNCTIONAL);
}
} else {
// No date provided by the AI → fall back to current date from the clock
resolvedDate = clockPort.now().atZone(java.time.ZoneOffset.UTC).toLocalDate();
dateSource = DateSource.FALLBACK_CURRENT;
}
NamingProposal proposal = new NamingProposal(resolvedDate, dateSource, title, parsed.reasoning());
return AiValidationResult.valid(proposal);
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
/**
* Returns {@code true} if every character in the title is a letter, digit, or space.
* <p>
* Permits Unicode letters including German Umlauts (ä, ö, ü, Ä, Ö, Ü) and ß.
*/
private static boolean isAllowedTitleCharacters(String title) {
for (int i = 0; i < title.length(); i++) {
char c = title.charAt(i);
if (!Character.isLetter(c) && !Character.isDigit(c) && c != ' ') {
return false;
}
}
return true;
}
/**
* Returns {@code true} if the title is a known generic placeholder.
* Comparison is case-insensitive.
*/
private static boolean isGenericTitle(String title) {
return GENERIC_TITLES.contains(title.toLowerCase());
}
// -------------------------------------------------------------------------
// Result type
// -------------------------------------------------------------------------
/**
* The result of a semantic AI response validation.
*/
public sealed interface AiValidationResult permits AiValidationResult.Valid, AiValidationResult.Invalid {
/**
* Returns a valid result containing the produced {@link NamingProposal}.
*
* @param proposal the validated naming proposal; must not be null
* @return a valid result; never null
*/
static AiValidationResult valid(NamingProposal proposal) {
return new Valid(proposal);
}
/**
* Returns an invalid result with an error message and classification.
*
* @param errorMessage human-readable description of the validation failure;
* must not be null
* @param classification always {@link AiErrorClassification#FUNCTIONAL} for
* semantic title/date violations
* @return an invalid result; never null
*/
static AiValidationResult invalid(String errorMessage, AiErrorClassification classification) {
return new Invalid(errorMessage, classification);
}
/**
* A successful validation result containing the ready {@link NamingProposal}.
*
* @param proposal the validated and complete naming proposal; never null
*/
record Valid(NamingProposal proposal) implements AiValidationResult {
public Valid {
Objects.requireNonNull(proposal, "proposal must not be null");
}
}
/**
* A failed validation result carrying the error details.
*
* @param errorMessage the reason for the failure; never null
* @param classification the error category; never null
*/
record Invalid(String errorMessage, AiErrorClassification classification)
implements AiValidationResult {
public Invalid {
Objects.requireNonNull(errorMessage, "errorMessage must not be null");
Objects.requireNonNull(classification, "classification must not be null");
}
}
}
}

View File

@@ -13,15 +13,26 @@ import de.gecheckt.pdf.umbenenner.application.port.out.PersistenceLookupTechnica
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt; import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttemptRepository; import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttemptRepository;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingLogger; import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingLogger;
import de.gecheckt.pdf.umbenenner.application.port.out.ResolvedTargetFilename;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopyPort;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopyResult;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopySuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopyTechnicalFailure;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFilenameResolutionResult;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFolderPort;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFolderTechnicalFailure;
import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort; import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort;
import de.gecheckt.pdf.umbenenner.domain.model.AiAttemptContext;
import de.gecheckt.pdf.umbenenner.domain.model.AiFunctionalFailure;
import de.gecheckt.pdf.umbenenner.domain.model.AiTechnicalFailure;
import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext; import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint; import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentProcessingOutcome; import de.gecheckt.pdf.umbenenner.domain.model.DocumentProcessingOutcome;
import de.gecheckt.pdf.umbenenner.domain.model.PreCheckFailed; import de.gecheckt.pdf.umbenenner.domain.model.NamingProposal;
import de.gecheckt.pdf.umbenenner.domain.model.NamingProposalReady;
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus; import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate; import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate;
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator; import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator;
import de.gecheckt.pdf.umbenenner.domain.model.TechnicalDocumentError;
import java.time.Instant; import java.time.Instant;
import java.util.Objects; import java.util.Objects;
@@ -32,7 +43,8 @@ import java.util.function.Function;
* Application-level service that implements the per-document processing logic. * Application-level service that implements the per-document processing logic.
* <p> * <p>
* This service is the single authoritative place for the decision rules: * This service is the single authoritative place for the decision rules:
* idempotency checks, status/counter mapping, and consistent two-level persistence. * idempotency checks, status/counter mapping, target-copy finalization, and consistent
* two-level persistence.
* *
* <h2>Processing order per candidate</h2> * <h2>Processing order per candidate</h2>
* <ol> * <ol>
@@ -41,56 +53,81 @@ import java.util.function.Function;
* a skip attempt with {@link ProcessingStatus#SKIPPED_ALREADY_PROCESSED}.</li> * a skip attempt with {@link ProcessingStatus#SKIPPED_ALREADY_PROCESSED}.</li>
* <li>If the overall status is {@link ProcessingStatus#FAILED_FINAL} → create and persist * <li>If the overall status is {@link ProcessingStatus#FAILED_FINAL} → create and persist
* a skip attempt with {@link ProcessingStatus#SKIPPED_FINAL_FAILURE}.</li> * a skip attempt with {@link ProcessingStatus#SKIPPED_FINAL_FAILURE}.</li>
* <li>Otherwise execute the flow (already done by the caller) and map the result * <li>If the overall status is {@link ProcessingStatus#PROPOSAL_READY} → load the
* into status, counters and retryable flag.</li> * leading proposal attempt and execute the target-copy finalization flow:
* build the base filename, resolve duplicates, write the copy, persist SUCCESS or
* FAILED_RETRYABLE.</li>
* <li>Otherwise execute the pipeline (extraction + pre-checks + AI naming) and map
* the result into status, counters, and retryable flag.</li>
* <li>Persist exactly one historised processing attempt for the identified document.</li> * <li>Persist exactly one historised processing attempt for the identified document.</li>
* <li>Persist the updated document master record.</li> * <li>Persist the updated document master record.</li>
* </ol> * </ol>
* *
* <h2>Minimal rules</h2> * <h2>Status transitions</h2>
* <ul> * <ul>
* <li>Already successful documents are skipped in later runs.</li> * <li>Pre-check passed + AI naming proposal ready → {@link ProcessingStatus#PROPOSAL_READY}</li>
* <li>Already finally failed documents are skipped in later runs.</li> * <li>First deterministic content failure → {@link ProcessingStatus#FAILED_RETRYABLE}</li>
* <li>First historised deterministic content failure from processing → * <li>Second deterministic content failure → {@link ProcessingStatus#FAILED_FINAL}</li>
* {@link ProcessingStatus#FAILED_RETRYABLE}, content error counter becomes 1, * <li>Technical infrastructure failure → {@link ProcessingStatus#FAILED_RETRYABLE}</li>
* {@code retryable=true}.</li> * <li>{@link ProcessingStatus#PROPOSAL_READY} + successful target copy + consistent
* <li>Second historised deterministic content failure in a later run → * persistence → {@link ProcessingStatus#SUCCESS}</li>
* {@link ProcessingStatus#FAILED_FINAL}, content error counter becomes 2, * <li>{@link ProcessingStatus#PROPOSAL_READY} + technical failure → {@link ProcessingStatus#FAILED_RETRYABLE},
* {@code retryable=false}.</li> * transient error counter +1</li>
* <li>Document-related technical failures after successful fingerprinting remain * <li>{@link ProcessingStatus#SUCCESS} → {@link ProcessingStatus#SKIPPED_ALREADY_PROCESSED} skip</li>
* {@link ProcessingStatus#FAILED_RETRYABLE}, increment transient error counter, * <li>{@link ProcessingStatus#FAILED_FINAL} → {@link ProcessingStatus#SKIPPED_FINAL_FAILURE} skip</li>
* {@code retryable=true}.</li>
* <li>Skip events do not change error counters.</li>
* </ul> * </ul>
* *
* <h2>Leading source for the naming proposal (verbindlich)</h2>
* <p>
* When a document is in {@code PROPOSAL_READY} state, the authoritative source for the
* validated title, resolved date, date source, and AI reasoning is the most recent
* {@code PROPOSAL_READY} attempt in the history. This coordinator never reconstructs
* proposal data from the document master record or re-invokes the AI when a valid
* {@code PROPOSAL_READY} attempt already exists.
*
* <h2>SUCCESS condition (verbindlich)</h2>
* <p>
* {@code SUCCESS} is set only after:
* <ol>
* <li>The target copy has been successfully written.</li>
* <li>The final target filename is determined.</li>
* <li>The persistence (attempt + master record) has been consistently committed.</li>
* </ol>
* If persistence fails after a successful target copy, a best-effort rollback of the
* newly written copy is attempted before the error is recorded.
*
* <h2>Persistence consistency</h2> * <h2>Persistence consistency</h2>
* <p> * <p>
* For every identified document, both the processing attempt and the master record are * For every identified document (except PROPOSAL_READY that fails before producing any
* written atomically using a unit of work pattern. If either write fails, both writes * persistent artifact), both the processing attempt and the master record are written
* are rolled back and the failure is logged. The batch run continues with the next * atomically via a unit of work. If either write fails, both writes are rolled back and
* candidate. * the failure is logged. The batch run continues with the next candidate.
* *
* <h2>Pre-fingerprint failures</h2> * <h2>Pre-fingerprint failures</h2>
* <p> * <p>
* Failures that occur before a successful fingerprint is available are <em>not</em> * Failures that occur before a successful fingerprint is available are <em>not</em>
* historised in SQLite. They are handled by the caller and logged as non-identifiable * historised in SQLite. They are handled by the caller.
* run events.
*/ */
public class DocumentProcessingCoordinator { public class DocumentProcessingCoordinator {
private final DocumentRecordRepository documentRecordRepository; private final DocumentRecordRepository documentRecordRepository;
private final ProcessingAttemptRepository processingAttemptRepository; private final ProcessingAttemptRepository processingAttemptRepository;
private final UnitOfWorkPort unitOfWorkPort; private final UnitOfWorkPort unitOfWorkPort;
private final TargetFolderPort targetFolderPort;
private final TargetFileCopyPort targetFileCopyPort;
private final ProcessingLogger logger; private final ProcessingLogger logger;
/** /**
* Creates the document processor with the required persistence ports and logger. * Creates the document processing coordinator with all required ports and the logger.
* *
* @param documentRecordRepository port for reading and writing the document master record; * @param documentRecordRepository port for reading and writing the document master record;
* must not be null * must not be null
* @param processingAttemptRepository port for writing and reading the attempt history; * @param processingAttemptRepository port for writing and reading the attempt history;
* must not be null * must not be null
* @param unitOfWorkPort port for executing operations atomically; * @param unitOfWorkPort port for executing operations atomically; must not be null
* @param targetFolderPort port for target folder duplicate resolution and cleanup;
* must not be null
* @param targetFileCopyPort port for copying source files to the target folder;
* must not be null * must not be null
* @param logger for processing-related logging; must not be null * @param logger for processing-related logging; must not be null
* @throws NullPointerException if any parameter is null * @throws NullPointerException if any parameter is null
@@ -99,6 +136,8 @@ public class DocumentProcessingCoordinator {
DocumentRecordRepository documentRecordRepository, DocumentRecordRepository documentRecordRepository,
ProcessingAttemptRepository processingAttemptRepository, ProcessingAttemptRepository processingAttemptRepository,
UnitOfWorkPort unitOfWorkPort, UnitOfWorkPort unitOfWorkPort,
TargetFolderPort targetFolderPort,
TargetFileCopyPort targetFileCopyPort,
ProcessingLogger logger) { ProcessingLogger logger) {
this.documentRecordRepository = this.documentRecordRepository =
Objects.requireNonNull(documentRecordRepository, "documentRecordRepository must not be null"); Objects.requireNonNull(documentRecordRepository, "documentRecordRepository must not be null");
@@ -106,31 +145,25 @@ public class DocumentProcessingCoordinator {
Objects.requireNonNull(processingAttemptRepository, "processingAttemptRepository must not be null"); Objects.requireNonNull(processingAttemptRepository, "processingAttemptRepository must not be null");
this.unitOfWorkPort = this.unitOfWorkPort =
Objects.requireNonNull(unitOfWorkPort, "unitOfWorkPort must not be null"); Objects.requireNonNull(unitOfWorkPort, "unitOfWorkPort must not be null");
this.targetFolderPort =
Objects.requireNonNull(targetFolderPort, "targetFolderPort must not be null");
this.targetFileCopyPort =
Objects.requireNonNull(targetFileCopyPort, "targetFileCopyPort must not be null");
this.logger = Objects.requireNonNull(logger, "logger must not be null"); this.logger = Objects.requireNonNull(logger, "logger must not be null");
} }
/** /**
* Applies the full processing logic for one identified document candidate. * Applies the full processing logic for one identified document candidate.
* <p> * <p>
* The caller must have already computed a valid {@link DocumentFingerprint} for the * Convenience overload that accepts a pre-computed outcome (for callers that have
* candidate. The outcome (from the PDF extraction and pre-check pipeline) is * already determined the outcome before calling this method).
* provided as {@code outcome} and is used only when the document is not in a
* terminal state.
* <p>
* This method never throws. All persistence failures are caught, logged, and
* treated as controlled per-document failures so the batch run can continue.
* *
* @param candidate the source document candidate being processed; must not be null * @param candidate the source document candidate being processed; must not be null
* @param fingerprint the successfully computed fingerprint for this candidate; * @param fingerprint the successfully computed fingerprint; must not be null
* must not be null * @param outcome the pipeline result; must not be null
* @param outcome the result of the extraction and pre-check pipeline; * @param context the current batch run context; must not be null
* must not be null * @param attemptStart the instant at which processing began; must not be null
* @param context the current batch run context (for run ID and timing); * @return true if processing and persistence succeeded, false if persistence failed
* must not be null
* @param attemptStart the instant at which processing of this candidate began;
* must not be null
* @return true if processing and persistence succeeded for this document, false if a
* persistence failure occurred
*/ */
public boolean process( public boolean process(
SourceDocumentCandidate candidate, SourceDocumentCandidate candidate,
@@ -149,33 +182,32 @@ public class DocumentProcessingCoordinator {
} }
/** /**
* Applies the full processing logic for one identified document candidate. * Applies the full processing logic for one identified document candidate,
* <p> * loading the document master record internally and deferring pipeline execution
* The caller must have already computed a valid {@link DocumentFingerprint} for the * until the terminal-state check passes.
* candidate. This method handles the complete processing flow:
* <ol>
* <li>Load document master record.</li>
* <li>Handle terminal SUCCESS / FAILED_FINAL skip cases first.</li>
* <li>Only if not terminal: execute the flow (PDF extraction + pre-checks).</li>
* <li>Map outcome to status, counters and retryable flag.</li>
* <li>Persist exactly one historised processing attempt.</li>
* <li>Persist the updated document master record.</li>
* </ol>
* <p> * <p>
* This method never throws. All persistence failures are caught, logged, and * This method never throws. All persistence failures are caught, logged, and
* treated as controlled per-document failures so the batch run can continue. * treated as controlled per-document failures so the batch run can continue.
* *
* @param candidate the source document candidate being processed; must not be null * <h2>Processing order</h2>
* @param fingerprint the successfully computed fingerprint for this candidate; * <ol>
* must not be null * <li>Load the document master record.</li>
* @param context the current batch run context (for run ID and timing); * <li>If the status is {@code SUCCESS} → persist
* must not be null * {@code SKIPPED_ALREADY_PROCESSED}.</li>
* @param attemptStart the instant at which processing of this candidate began; * <li>If the status is {@code FAILED_FINAL} → persist
* must not be null * {@code SKIPPED_FINAL_FAILURE}.</li>
* @param pipelineExecutor functional interface that executes the extraction and pre-check * <li>If the status is {@code PROPOSAL_READY} → execute the target-copy
* pipeline when needed; must not be null * finalization without invoking the AI pipeline again.</li>
* @return true if processing and persistence succeeded for this document, false if a * <li>Otherwise execute the pipeline (extraction + pre-checks + AI naming) and
* persistence failure occurred (lookup, attempt write, or record write) * persist the outcome.</li>
* </ol>
*
* @param candidate the source document candidate; must not be null
* @param fingerprint the successfully computed fingerprint; must not be null
* @param context the current batch run context; must not be null
* @param attemptStart the instant at which processing began; must not be null
* @param pipelineExecutor executes the extraction + AI pipeline when needed; must not be null
* @return true if processing and persistence succeeded, false if a persistence failure occurred
*/ */
public boolean processDeferredOutcome( public boolean processDeferredOutcome(
SourceDocumentCandidate candidate, SourceDocumentCandidate candidate,
@@ -194,7 +226,7 @@ public class DocumentProcessingCoordinator {
DocumentRecordLookupResult lookupResult = DocumentRecordLookupResult lookupResult =
documentRecordRepository.findByFingerprint(fingerprint); documentRecordRepository.findByFingerprint(fingerprint);
// Step 2: Handle persistence lookup failure cannot safely proceed // Step 2: Handle persistence lookup failure
if (lookupResult instanceof PersistenceLookupTechnicalFailure failure) { if (lookupResult instanceof PersistenceLookupTechnicalFailure failure) {
logger.error("Cannot process '{}': master record lookup failed: {}", logger.error("Cannot process '{}': master record lookup failed: {}",
candidate.uniqueIdentifier(), failure.errorMessage()); candidate.uniqueIdentifier(), failure.errorMessage());
@@ -204,7 +236,6 @@ public class DocumentProcessingCoordinator {
// Step 3: Determine the action based on the lookup result // Step 3: Determine the action based on the lookup result
return switch (lookupResult) { return switch (lookupResult) {
case DocumentTerminalSuccess terminalSuccess -> { case DocumentTerminalSuccess terminalSuccess -> {
// Document already successfully processed → skip
logger.info("Skipping '{}': already successfully processed (fingerprint: {}).", logger.info("Skipping '{}': already successfully processed (fingerprint: {}).",
candidate.uniqueIdentifier(), fingerprint.sha256Hex()); candidate.uniqueIdentifier(), fingerprint.sha256Hex());
yield persistSkipAttempt( yield persistSkipAttempt(
@@ -214,7 +245,6 @@ public class DocumentProcessingCoordinator {
} }
case DocumentTerminalFinalFailure terminalFailure -> { case DocumentTerminalFinalFailure terminalFailure -> {
// Document finally failed → skip
logger.info("Skipping '{}': already finally failed (fingerprint: {}).", logger.info("Skipping '{}': already finally failed (fingerprint: {}).",
candidate.uniqueIdentifier(), fingerprint.sha256Hex()); candidate.uniqueIdentifier(), fingerprint.sha256Hex());
yield persistSkipAttempt( yield persistSkipAttempt(
@@ -223,14 +253,23 @@ public class DocumentProcessingCoordinator {
context, attemptStart); context, attemptStart);
} }
case DocumentKnownProcessable knownProcessable
when knownProcessable.record().overallStatus() == ProcessingStatus.PROPOSAL_READY -> {
// Naming proposal is present — execute the target-copy finalization
// without triggering a new AI call
logger.info("Finalizing '{}': naming proposal present, proceeding to target copy "
+ "(fingerprint: {}).",
candidate.uniqueIdentifier(), fingerprint.sha256Hex());
yield finalizeProposalReady(
candidate, fingerprint, knownProcessable.record(), context, attemptStart);
}
case DocumentUnknown ignored -> { case DocumentUnknown ignored -> {
// New document execute pipeline and process
DocumentProcessingOutcome outcome = pipelineExecutor.apply(candidate); DocumentProcessingOutcome outcome = pipelineExecutor.apply(candidate);
yield processAndPersistNewDocument(candidate, fingerprint, outcome, context, attemptStart); yield processAndPersistNewDocument(candidate, fingerprint, outcome, context, attemptStart);
} }
case DocumentKnownProcessable knownProcessable -> { case DocumentKnownProcessable knownProcessable -> {
// Known but not terminal execute pipeline and process
DocumentProcessingOutcome outcome = pipelineExecutor.apply(candidate); DocumentProcessingOutcome outcome = pipelineExecutor.apply(candidate);
yield processAndPersistKnownDocument( yield processAndPersistKnownDocument(
candidate, fingerprint, outcome, knownProcessable.record(), candidate, fingerprint, outcome, knownProcessable.record(),
@@ -238,7 +277,6 @@ public class DocumentProcessingCoordinator {
} }
default -> { default -> {
// Exhaustive sealed hierarchy; this branch is unreachable
logger.error("Unexpected lookup result type for '{}': {}", logger.error("Unexpected lookup result type for '{}': {}",
candidate.uniqueIdentifier(), lookupResult.getClass().getSimpleName()); candidate.uniqueIdentifier(), lookupResult.getClass().getSimpleName());
yield false; yield false;
@@ -246,24 +284,259 @@ public class DocumentProcessingCoordinator {
}; };
} }
// ------------------------------------------------------------------------- // =========================================================================
// M6 target-copy finalization path
// =========================================================================
/**
* Finalizes a document whose status is {@code PROPOSAL_READY}.
* <p>
* Processing order:
* <ol>
* <li>Load the leading {@code PROPOSAL_READY} attempt (authoritative proposal source).</li>
* <li>Build the base filename from the proposal's date and title.</li>
* <li>Resolve the first available unique filename in the target folder.</li>
* <li>Copy the source file to the target folder.</li>
* <li>Persist a new {@code SUCCESS} attempt and update the master record.</li>
* <li>If persistence fails after a successful copy: attempt best-effort rollback
* of the copy and persist {@code FAILED_RETRYABLE} instead.</li>
* </ol>
* <p>
* A missing or inconsistent {@code PROPOSAL_READY} attempt is treated as a
* document-level technical error (retryable, transient counter +1).
*
* @return true if SUCCESS was persisted, false if a persistence failure occurred
*/
private boolean finalizeProposalReady(
SourceDocumentCandidate candidate,
DocumentFingerprint fingerprint,
DocumentRecord existingRecord,
BatchRunContext context,
Instant attemptStart) {
Instant now = Instant.now();
// --- Step 1: Load the leading PROPOSAL_READY attempt ---
ProcessingAttempt proposalAttempt;
try {
proposalAttempt = processingAttemptRepository.findLatestProposalReadyAttempt(fingerprint);
} catch (DocumentPersistenceException e) {
logger.error("Failed to load leading PROPOSAL_READY attempt for '{}': {}",
candidate.uniqueIdentifier(), e.getMessage(), e);
return persistTransientError(
candidate, fingerprint, existingRecord, context, attemptStart, now,
"Failed to load naming proposal from history: " + e.getMessage());
}
if (proposalAttempt == null) {
logger.error("Document '{}' has PROPOSAL_READY status but no matching attempt "
+ "found in history. Inconsistent persistence state.",
candidate.uniqueIdentifier());
return persistTransientError(
candidate, fingerprint, existingRecord, context, attemptStart, now,
"Status is PROPOSAL_READY but no PROPOSAL_READY attempt exists in history");
}
// --- Step 2: Build base filename from the proposal ---
TargetFilenameBuildingService.BaseFilenameResult filenameResult =
TargetFilenameBuildingService.buildBaseFilename(proposalAttempt);
if (filenameResult instanceof TargetFilenameBuildingService.InconsistentProposalState inconsistent) {
logger.error("Inconsistent proposal state for '{}': {}",
candidate.uniqueIdentifier(), inconsistent.reason());
return persistTransientError(
candidate, fingerprint, existingRecord, context, attemptStart, now,
"Inconsistent proposal state: " + inconsistent.reason());
}
String baseFilename = ((TargetFilenameBuildingService.BaseFilenameReady) filenameResult).baseFilename();
// --- Step 3: Resolve unique filename in target folder ---
TargetFilenameResolutionResult resolutionResult =
targetFolderPort.resolveUniqueFilename(baseFilename);
if (resolutionResult instanceof TargetFolderTechnicalFailure folderFailure) {
logger.error("Duplicate resolution failed for '{}': {}",
candidate.uniqueIdentifier(), folderFailure.errorMessage());
return persistTransientError(
candidate, fingerprint, existingRecord, context, attemptStart, now,
"Target folder duplicate resolution failed: " + folderFailure.errorMessage());
}
String resolvedFilename =
((ResolvedTargetFilename) resolutionResult).resolvedFilename();
logger.info("Resolved target filename for '{}': '{}'.",
candidate.uniqueIdentifier(), resolvedFilename);
// --- Step 4: Copy file to target ---
TargetFileCopyResult copyResult =
targetFileCopyPort.copyToTarget(candidate.locator(), resolvedFilename);
if (copyResult instanceof TargetFileCopyTechnicalFailure copyFailure) {
logger.error("Target copy failed for '{}': {}",
candidate.uniqueIdentifier(), copyFailure.errorMessage());
return persistTransientError(
candidate, fingerprint, existingRecord, context, attemptStart, now,
"Target file copy failed: " + copyFailure.errorMessage());
}
// Copy succeeded — attempt to persist SUCCESS
// If persistence fails: rollback the copy (best-effort) and persist FAILED_RETRYABLE
String targetFolderLocator = targetFolderPort.getTargetFolderLocator();
return persistTargetCopySuccess(
candidate, fingerprint, existingRecord, context, attemptStart, now,
resolvedFilename, targetFolderLocator);
}
/**
* Persists the SUCCESS attempt and updated master record after a successful target copy.
* <p>
* If the atomic persistence fails after the copy has already been written, a
* best-effort rollback of the target file is attempted and
* {@link ProcessingStatus#FAILED_RETRYABLE} is persisted instead.
*
* @return true if SUCCESS was persisted; false if persistence itself failed
*/
private boolean persistTargetCopySuccess(
SourceDocumentCandidate candidate,
DocumentFingerprint fingerprint,
DocumentRecord existingRecord,
BatchRunContext context,
Instant attemptStart,
Instant now,
String resolvedFilename,
String targetFolderLocator) {
try {
int attemptNumber = processingAttemptRepository.loadNextAttemptNumber(fingerprint);
ProcessingAttempt successAttempt = new ProcessingAttempt(
fingerprint, context.runId(), attemptNumber, attemptStart, now,
ProcessingStatus.SUCCESS, null, null, false,
null, null, null, null, null, null, null, null, null,
resolvedFilename);
DocumentRecord successRecord = buildSuccessRecord(
existingRecord, candidate, now, targetFolderLocator, resolvedFilename);
unitOfWorkPort.executeInTransaction(txOps -> {
txOps.saveProcessingAttempt(successAttempt);
txOps.updateDocumentRecord(successRecord);
});
logger.info("Document '{}' successfully processed. Target: '{}'.",
candidate.uniqueIdentifier(), resolvedFilename);
return true;
} catch (DocumentPersistenceException e) {
// Persistence failed after a successful copy — rollback the copy (best-effort)
logger.error("Persistence failed after successful target copy for '{}': {}. "
+ "Attempting best-effort rollback of target file '{}'.",
candidate.uniqueIdentifier(), e.getMessage(), resolvedFilename);
targetFolderPort.tryDeleteTargetFile(resolvedFilename);
// Persist FAILED_RETRYABLE to record the incident
persistTransientErrorAfterPersistenceFailure(
candidate, fingerprint, existingRecord, context, attemptStart,
Instant.now(),
"Persistence failed after successful target copy (best-effort rollback attempted): "
+ e.getMessage());
return false;
}
}
/**
* Persists a {@code FAILED_RETRYABLE} attempt with an incremented transient error counter
* for a document-level technical error during the target-copy finalization stage.
*
* @return true if the error was persisted; false if the error persistence itself failed
*/
private boolean persistTransientError(
SourceDocumentCandidate candidate,
DocumentFingerprint fingerprint,
DocumentRecord existingRecord,
BatchRunContext context,
Instant attemptStart,
Instant now,
String errorMessage) {
FailureCounters updatedCounters =
existingRecord.failureCounters().withIncrementedTransientErrorCount();
try {
int attemptNumber = processingAttemptRepository.loadNextAttemptNumber(fingerprint);
ProcessingAttempt errorAttempt = ProcessingAttempt.withoutAiFields(
fingerprint, context.runId(), attemptNumber, attemptStart, now,
ProcessingStatus.FAILED_RETRYABLE,
ProcessingStatus.FAILED_RETRYABLE.name(),
errorMessage, true);
DocumentRecord errorRecord = buildTransientErrorRecord(
existingRecord, candidate, updatedCounters, now);
unitOfWorkPort.executeInTransaction(txOps -> {
txOps.saveProcessingAttempt(errorAttempt);
txOps.updateDocumentRecord(errorRecord);
});
logger.debug("Transient error persisted for '{}': status=FAILED_RETRYABLE, "
+ "transientErrors={}.",
candidate.uniqueIdentifier(),
updatedCounters.transientErrorCount());
return true;
} catch (DocumentPersistenceException persistEx) {
logger.error("Failed to persist transient error for '{}': {}",
candidate.uniqueIdentifier(), persistEx.getMessage(), persistEx);
return false;
}
}
/**
* Attempts to persist a {@code FAILED_RETRYABLE} attempt after a persistence failure
* that occurred following a successful target copy. This is a secondary persistence
* effort; its failure is logged but does not change the return value.
*/
private void persistTransientErrorAfterPersistenceFailure(
SourceDocumentCandidate candidate,
DocumentFingerprint fingerprint,
DocumentRecord existingRecord,
BatchRunContext context,
Instant attemptStart,
Instant now,
String errorMessage) {
FailureCounters updatedCounters =
existingRecord.failureCounters().withIncrementedTransientErrorCount();
try {
int attemptNumber = processingAttemptRepository.loadNextAttemptNumber(fingerprint);
ProcessingAttempt errorAttempt = ProcessingAttempt.withoutAiFields(
fingerprint, context.runId(), attemptNumber, attemptStart, now,
ProcessingStatus.FAILED_RETRYABLE,
ProcessingStatus.FAILED_RETRYABLE.name(),
errorMessage, true);
DocumentRecord errorRecord = buildTransientErrorRecord(
existingRecord, candidate, updatedCounters, now);
unitOfWorkPort.executeInTransaction(txOps -> {
txOps.saveProcessingAttempt(errorAttempt);
txOps.updateDocumentRecord(errorRecord);
});
} catch (DocumentPersistenceException secondaryEx) {
logger.error("Secondary persistence failure for '{}' after target copy rollback: {}",
candidate.uniqueIdentifier(), secondaryEx.getMessage(), secondaryEx);
}
}
// =========================================================================
// Skip path // Skip path
// ------------------------------------------------------------------------- // =========================================================================
/** /**
* Persists a skip attempt and updates the master record's {@code updatedAt} timestamp. * Persists a skip attempt and updates the master record's {@code updatedAt} timestamp.
* <p> * Skip events do not change any failure counter or overall status.
* Skip events do not change any failure counter. The master record's overall status
* remains unchanged (terminal).
*
* @param candidate the candidate being skipped
* @param fingerprint the document fingerprint
* @param existingRecord the current master record (already terminal)
* @param skipStatus the skip status to record ({@link ProcessingStatus#SKIPPED_ALREADY_PROCESSED}
* or {@link ProcessingStatus#SKIPPED_FINAL_FAILURE})
* @param context the current batch run context
* @param attemptStart the start instant of this processing attempt
* @return true if persistence succeeded, false if a persistence exception occurred
*/ */
private boolean persistSkipAttempt( private boolean persistSkipAttempt(
SourceDocumentCandidate candidate, SourceDocumentCandidate candidate,
@@ -278,21 +551,13 @@ public class DocumentProcessingCoordinator {
try { try {
int attemptNumber = processingAttemptRepository.loadNextAttemptNumber(fingerprint); int attemptNumber = processingAttemptRepository.loadNextAttemptNumber(fingerprint);
ProcessingAttempt skipAttempt = new ProcessingAttempt( ProcessingAttempt skipAttempt = ProcessingAttempt.withoutAiFields(
fingerprint, fingerprint, context.runId(), attemptNumber,
context.runId(), attemptStart, now, skipStatus,
attemptNumber, null, null, false);
attemptStart,
now,
skipStatus,
null, // no failure class for skip
null, // no failure message for skip
false // not retryable
);
DocumentRecord skipRecord = buildSkipRecord(existingRecord, candidate, now); DocumentRecord skipRecord = buildSkipRecord(existingRecord, candidate, now);
// Write attempt and master record atomically
unitOfWorkPort.executeInTransaction(txOps -> { unitOfWorkPort.executeInTransaction(txOps -> {
txOps.saveProcessingAttempt(skipAttempt); txOps.saveProcessingAttempt(skipAttempt);
txOps.updateDocumentRecord(skipRecord); txOps.updateDocumentRecord(skipRecord);
@@ -309,11 +574,10 @@ public class DocumentProcessingCoordinator {
} }
} }
// ------------------------------------------------------------------------- // =========================================================================
// New document path // New document path
// ------------------------------------------------------------------------- // =========================================================================
/** Maps the pipeline outcome for a new document and persists attempt + new master record. */
private boolean processAndPersistNewDocument( private boolean processAndPersistNewDocument(
SourceDocumentCandidate candidate, SourceDocumentCandidate candidate,
DocumentFingerprint fingerprint, DocumentFingerprint fingerprint,
@@ -325,14 +589,13 @@ public class DocumentProcessingCoordinator {
ProcessingOutcomeTransition.ProcessingOutcome outcome = mapOutcomeForNewDocument(pipelineOutcome); ProcessingOutcomeTransition.ProcessingOutcome outcome = mapOutcomeForNewDocument(pipelineOutcome);
DocumentRecord newRecord = buildNewDocumentRecord(fingerprint, candidate, outcome, now); DocumentRecord newRecord = buildNewDocumentRecord(fingerprint, candidate, outcome, now);
return persistAttemptAndRecord(candidate, fingerprint, context, attemptStart, now, outcome, return persistAttemptAndRecord(candidate, fingerprint, context, attemptStart, now, outcome,
txOps -> txOps.createDocumentRecord(newRecord)); pipelineOutcome, txOps -> txOps.createDocumentRecord(newRecord));
} }
// ------------------------------------------------------------------------- // =========================================================================
// Known processable document path // Known processable document path (non-PROPOSAL_READY)
// ------------------------------------------------------------------------- // =========================================================================
/** Maps the pipeline outcome for a known document and persists attempt + updated master record. */
private boolean processAndPersistKnownDocument( private boolean processAndPersistKnownDocument(
SourceDocumentCandidate candidate, SourceDocumentCandidate candidate,
DocumentFingerprint fingerprint, DocumentFingerprint fingerprint,
@@ -342,62 +605,50 @@ public class DocumentProcessingCoordinator {
Instant attemptStart) { Instant attemptStart) {
Instant now = Instant.now(); Instant now = Instant.now();
ProcessingOutcomeTransition.ProcessingOutcome outcome = mapOutcomeForKnownDocument(pipelineOutcome, existingRecord.failureCounters()); ProcessingOutcomeTransition.ProcessingOutcome outcome =
mapOutcomeForKnownDocument(pipelineOutcome, existingRecord.failureCounters());
DocumentRecord updatedRecord = buildUpdatedDocumentRecord(existingRecord, candidate, outcome, now); DocumentRecord updatedRecord = buildUpdatedDocumentRecord(existingRecord, candidate, outcome, now);
return persistAttemptAndRecord(candidate, fingerprint, context, attemptStart, now, outcome, return persistAttemptAndRecord(candidate, fingerprint, context, attemptStart, now, outcome,
txOps -> txOps.updateDocumentRecord(updatedRecord)); pipelineOutcome, txOps -> txOps.updateDocumentRecord(updatedRecord));
} }
// ------------------------------------------------------------------------- // =========================================================================
// Extraction outcome mapping // Extraction outcome mapping
// ------------------------------------------------------------------------- // =========================================================================
/**
* Maps an outcome to status, counters, and retryable flag for a brand-new
* document (no prior history, counters start at zero).
*
* @param pipelineOutcome the pipeline result
* @return the outcome with status, counters and retryable flag
*/
private ProcessingOutcomeTransition.ProcessingOutcome mapOutcomeForNewDocument( private ProcessingOutcomeTransition.ProcessingOutcome mapOutcomeForNewDocument(
DocumentProcessingOutcome pipelineOutcome) { DocumentProcessingOutcome pipelineOutcome) {
return ProcessingOutcomeTransition.forNewDocument(pipelineOutcome); return ProcessingOutcomeTransition.forNewDocument(pipelineOutcome);
} }
/**
* Maps an outcome to status, counters, and retryable flag, taking the
* existing failure counters into account.
*
* @param pipelineOutcome the pipeline result
* @param existingCounters the current failure counters from the master record
* @return the outcome with updated status, counters and retryable flag
*/
private ProcessingOutcomeTransition.ProcessingOutcome mapOutcomeForKnownDocument( private ProcessingOutcomeTransition.ProcessingOutcome mapOutcomeForKnownDocument(
DocumentProcessingOutcome pipelineOutcome, DocumentProcessingOutcome pipelineOutcome,
FailureCounters existingCounters) { FailureCounters existingCounters) {
return ProcessingOutcomeTransition.forKnownDocument(pipelineOutcome, existingCounters); return ProcessingOutcomeTransition.forKnownDocument(pipelineOutcome, existingCounters);
} }
// ------------------------------------------------------------------------- // =========================================================================
// Record assembly helpers // Record assembly helpers
// ------------------------------------------------------------------------- // =========================================================================
private DocumentRecord buildNewDocumentRecord( private DocumentRecord buildNewDocumentRecord(
DocumentFingerprint fingerprint, DocumentFingerprint fingerprint,
SourceDocumentCandidate candidate, SourceDocumentCandidate candidate,
ProcessingOutcomeTransition.ProcessingOutcome outcome, ProcessingOutcomeTransition.ProcessingOutcome outcome,
Instant now) { Instant now) {
boolean success = outcome.overallStatus() == ProcessingStatus.SUCCESS; boolean isProposalReady = outcome.overallStatus() == ProcessingStatus.PROPOSAL_READY;
return new DocumentRecord( return new DocumentRecord(
fingerprint, fingerprint,
new SourceDocumentLocator(candidate.locator().value()), new SourceDocumentLocator(candidate.locator().value()),
candidate.uniqueIdentifier(), candidate.uniqueIdentifier(),
outcome.overallStatus(), outcome.overallStatus(),
outcome.counters(), outcome.counters(),
success ? null : now, // lastFailureInstant isProposalReady ? null : now, // lastFailureInstant
success ? now : null, // lastSuccessInstant null, // lastSuccessInstant (only on final SUCCESS)
now, // createdAt now, // createdAt
now // updatedAt now, // updatedAt
null, // lastTargetPath (not yet set)
null // lastTargetFileName (not yet set)
); );
} }
@@ -406,21 +657,22 @@ public class DocumentProcessingCoordinator {
SourceDocumentCandidate candidate, SourceDocumentCandidate candidate,
ProcessingOutcomeTransition.ProcessingOutcome outcome, ProcessingOutcomeTransition.ProcessingOutcome outcome,
Instant now) { Instant now) {
boolean success = outcome.overallStatus() == ProcessingStatus.SUCCESS; boolean isProposalReady = outcome.overallStatus() == ProcessingStatus.PROPOSAL_READY;
return new DocumentRecord( return new DocumentRecord(
existingRecord.fingerprint(), existingRecord.fingerprint(),
new SourceDocumentLocator(candidate.locator().value()), new SourceDocumentLocator(candidate.locator().value()),
candidate.uniqueIdentifier(), candidate.uniqueIdentifier(),
outcome.overallStatus(), outcome.overallStatus(),
outcome.counters(), outcome.counters(),
success ? existingRecord.lastFailureInstant() : now, isProposalReady ? existingRecord.lastFailureInstant() : now,
success ? now : existingRecord.lastSuccessInstant(), existingRecord.lastSuccessInstant(), // success only set by target-copy finalization
existingRecord.createdAt(), existingRecord.createdAt(),
now // updatedAt now, // updatedAt
existingRecord.lastTargetPath(), // carry over, not changed here
existingRecord.lastTargetFileName() // carry over, not changed here
); );
} }
/** Builds a skip record: only {@code updatedAt} advances; status and counters are unchanged. */
private DocumentRecord buildSkipRecord( private DocumentRecord buildSkipRecord(
DocumentRecord existingRecord, DocumentRecord existingRecord,
SourceDocumentCandidate candidate, SourceDocumentCandidate candidate,
@@ -434,21 +686,60 @@ public class DocumentProcessingCoordinator {
existingRecord.lastFailureInstant(), existingRecord.lastFailureInstant(),
existingRecord.lastSuccessInstant(), existingRecord.lastSuccessInstant(),
existingRecord.createdAt(), existingRecord.createdAt(),
now // updatedAt now, // updatedAt
existingRecord.lastTargetPath(),
existingRecord.lastTargetFileName()
); );
} }
// ------------------------------------------------------------------------- private DocumentRecord buildSuccessRecord(
// Common persistence flow (non-skip paths) DocumentRecord existingRecord,
// ------------------------------------------------------------------------- SourceDocumentCandidate candidate,
Instant now,
String targetFolderLocator,
String resolvedFilename) {
return new DocumentRecord(
existingRecord.fingerprint(),
new SourceDocumentLocator(candidate.locator().value()),
candidate.uniqueIdentifier(),
ProcessingStatus.SUCCESS,
existingRecord.failureCounters(), // counters unchanged on success
existingRecord.lastFailureInstant(),
now, // lastSuccessInstant
existingRecord.createdAt(),
now, // updatedAt
targetFolderLocator, // lastTargetPath
resolvedFilename // lastTargetFileName
);
}
private DocumentRecord buildTransientErrorRecord(
DocumentRecord existingRecord,
SourceDocumentCandidate candidate,
FailureCounters updatedCounters,
Instant now) {
return new DocumentRecord(
existingRecord.fingerprint(),
new SourceDocumentLocator(candidate.locator().value()),
candidate.uniqueIdentifier(),
ProcessingStatus.FAILED_RETRYABLE,
updatedCounters,
now, // lastFailureInstant
existingRecord.lastSuccessInstant(),
existingRecord.createdAt(),
now, // updatedAt
existingRecord.lastTargetPath(), // carry over
existingRecord.lastTargetFileName() // carry over
);
}
// =========================================================================
// Common persistence flow (AI pipeline path)
// =========================================================================
/** /**
* Loads the next attempt number, builds and persists the attempt together with the * Loads the next attempt number, builds and persists the attempt together with the
* document record atomically, then logs the result. * document record atomically.
* <p>
* {@code recordWriter} performs either {@code createDocumentRecord} or
* {@code updateDocumentRecord} depending on whether the document is new or known.
* All persistence failures are caught and logged; the batch run continues.
* *
* @return true if persistence succeeded, false if a persistence exception occurred * @return true if persistence succeeded, false if a persistence exception occurred
*/ */
@@ -459,12 +750,14 @@ public class DocumentProcessingCoordinator {
Instant attemptStart, Instant attemptStart,
Instant now, Instant now,
ProcessingOutcomeTransition.ProcessingOutcome outcome, ProcessingOutcomeTransition.ProcessingOutcome outcome,
DocumentProcessingOutcome pipelineOutcome,
Consumer<UnitOfWorkPort.TransactionOperations> recordWriter) { Consumer<UnitOfWorkPort.TransactionOperations> recordWriter) {
try { try {
int attemptNumber = processingAttemptRepository.loadNextAttemptNumber(fingerprint); int attemptNumber = processingAttemptRepository.loadNextAttemptNumber(fingerprint);
ProcessingAttempt attempt = ProcessingAttempt attempt =
buildAttempt(fingerprint, context, attemptNumber, attemptStart, now, outcome); buildAttempt(fingerprint, context, attemptNumber, attemptStart, now,
outcome, pipelineOutcome);
unitOfWorkPort.executeInTransaction(txOps -> { unitOfWorkPort.executeInTransaction(txOps -> {
txOps.saveProcessingAttempt(attempt); txOps.saveProcessingAttempt(attempt);
@@ -485,20 +778,14 @@ public class DocumentProcessingCoordinator {
} }
} }
// ------------------------------------------------------------------------- // =========================================================================
// Helper: build ProcessingAttempt // Attempt builder (AI pipeline path)
// ------------------------------------------------------------------------- // =========================================================================
/** /**
* Constructs a {@link ProcessingAttempt} from the given parameters and outcome. * Constructs a {@link ProcessingAttempt} from the pipeline outcome, including AI
* * traceability fields when available. The {@code finalTargetFileName} is null for
* @param fingerprint the document fingerprint * all pipeline-path attempts (target copy is handled separately).
* @param context the current batch run context
* @param attemptNumber the monotonic attempt number
* @param startedAt the start instant of this attempt
* @param endedAt the end instant of this attempt
* @param outcome the outcome (status, counters, retryable)
* @return the constructed processing attempt
*/ */
private ProcessingAttempt buildAttempt( private ProcessingAttempt buildAttempt(
DocumentFingerprint fingerprint, DocumentFingerprint fingerprint,
@@ -506,7 +793,8 @@ public class DocumentProcessingCoordinator {
int attemptNumber, int attemptNumber,
Instant startedAt, Instant startedAt,
Instant endedAt, Instant endedAt,
ProcessingOutcomeTransition.ProcessingOutcome outcome) { ProcessingOutcomeTransition.ProcessingOutcome outcome,
DocumentProcessingOutcome pipelineOutcome) {
String failureClass = null; String failureClass = null;
String failureMessage = null; String failureMessage = null;
@@ -514,38 +802,80 @@ public class DocumentProcessingCoordinator {
if (outcome.overallStatus() == ProcessingStatus.FAILED_RETRYABLE if (outcome.overallStatus() == ProcessingStatus.FAILED_RETRYABLE
|| outcome.overallStatus() == ProcessingStatus.FAILED_FINAL) { || outcome.overallStatus() == ProcessingStatus.FAILED_FINAL) {
failureClass = outcome.overallStatus().name(); failureClass = outcome.overallStatus().name();
failureMessage = buildFailureMessage(outcome); failureMessage = buildFailureMessage(pipelineOutcome, outcome);
} }
return new ProcessingAttempt( return switch (pipelineOutcome) {
fingerprint, case NamingProposalReady proposalReady -> {
context.runId(), AiAttemptContext ctx = proposalReady.aiContext();
attemptNumber, NamingProposal proposal = proposalReady.proposal();
startedAt, yield new ProcessingAttempt(
endedAt, fingerprint, context.runId(), attemptNumber, startedAt, endedAt,
outcome.overallStatus(), outcome.overallStatus(), failureClass, failureMessage, outcome.retryable(),
failureClass, ctx.modelName(), ctx.promptIdentifier(),
failureMessage, ctx.processedPageCount(), ctx.sentCharacterCount(),
outcome.retryable() ctx.aiRawResponse(),
proposal.aiReasoning(),
proposal.resolvedDate(), proposal.dateSource(), proposal.validatedTitle(),
null // finalTargetFileName — set only on SUCCESS attempts
); );
} }
case AiTechnicalFailure techFail -> {
/** AiAttemptContext ctx = techFail.aiContext();
* Builds a human-readable failure message from the outcome. yield new ProcessingAttempt(
* fingerprint, context.runId(), attemptNumber, startedAt, endedAt,
* @param outcome the outcome outcome.overallStatus(), failureClass, failureMessage, outcome.retryable(),
* @return a non-null failure message string ctx.modelName(), ctx.promptIdentifier(),
*/ ctx.processedPageCount(), ctx.sentCharacterCount(),
private String buildFailureMessage(ProcessingOutcomeTransition.ProcessingOutcome outcome) { ctx.aiRawResponse(),
return switch (outcome.overallStatus()) { null, null, null, null,
case FAILED_RETRYABLE -> "Processing failed (retryable). " null // finalTargetFileName
+ "ContentErrors=" + outcome.counters().contentErrorCount() );
+ ", TransientErrors=" + outcome.counters().transientErrorCount(); }
case FAILED_FINAL -> "Processing failed finally (not retryable). " case AiFunctionalFailure funcFail -> {
+ "ContentErrors=" + outcome.counters().contentErrorCount() AiAttemptContext ctx = funcFail.aiContext();
+ ", TransientErrors=" + outcome.counters().transientErrorCount(); yield new ProcessingAttempt(
default -> outcome.overallStatus().name(); fingerprint, context.runId(), attemptNumber, startedAt, endedAt,
outcome.overallStatus(), failureClass, failureMessage, outcome.retryable(),
ctx.modelName(), ctx.promptIdentifier(),
ctx.processedPageCount(), ctx.sentCharacterCount(),
ctx.aiRawResponse(),
null, null, null, null,
null // finalTargetFileName
);
}
default -> ProcessingAttempt.withoutAiFields(
fingerprint, context.runId(), attemptNumber, startedAt, endedAt,
outcome.overallStatus(), failureClass, failureMessage, outcome.retryable()
);
}; };
} }
/**
* Builds a human-readable failure message from the pipeline outcome and status outcome.
*/
private String buildFailureMessage(
DocumentProcessingOutcome pipelineOutcome,
ProcessingOutcomeTransition.ProcessingOutcome outcome) {
String base = switch (outcome.overallStatus()) {
case FAILED_RETRYABLE -> "Processing failed (retryable). ";
case FAILED_FINAL -> "Processing failed finally (not retryable). ";
default -> outcome.overallStatus().name() + ". ";
};
String detail = switch (pipelineOutcome) {
case de.gecheckt.pdf.umbenenner.domain.model.PreCheckFailed pf ->
"Reason: " + pf.failureReasonDescription();
case de.gecheckt.pdf.umbenenner.domain.model.TechnicalDocumentError te ->
"Technical: " + te.errorMessage();
case AiTechnicalFailure ai ->
"AI technical error: " + ai.errorMessage();
case AiFunctionalFailure ai ->
"AI functional error: " + ai.errorMessage();
default -> "ContentErrors=" + outcome.counters().contentErrorCount()
+ ", TransientErrors=" + outcome.counters().transientErrorCount();
};
return base + detail;
}
} }

View File

@@ -0,0 +1,55 @@
package de.gecheckt.pdf.umbenenner.application.service;
import java.util.Objects;
/**
* Utility for limiting extracted document text to the configured maximum character count.
* <p>
* The limitation is applied strictly <em>before</em> an AI request is composed.
* It operates on the extracted text as a character-count boundary without considering
* word or sentence boundaries, which is intentional: the AI is expected to handle
* partial text gracefully.
*
* <h2>Semantics</h2>
* <ul>
* <li>If the text length does not exceed the configured maximum, it is returned unchanged.</li>
* <li>If the text length exceeds the maximum, it is truncated to exactly
* {@code maxCharacters} characters.</li>
* </ul>
*
* <h2>Architecture boundary</h2>
* <p>
* This limiter does <em>not</em> modify the originally extracted document text stored
* elsewhere in the pipeline. It produces a new, potentially shorter copy suitable
* for inclusion in the AI request. The caller is responsible for recording the
* effective character count (i.e., the length of the returned string) for persistence.
*/
public final class DocumentTextLimiter {
private DocumentTextLimiter() {
// Static utility no instances
}
/**
* Returns the document text limited to {@code maxCharacters} characters.
* <p>
* If {@code text.length() <= maxCharacters} the original text is returned unchanged.
* Otherwise the first {@code maxCharacters} characters are returned as a new string.
*
* @param text the extracted document text; must not be null
* @param maxCharacters the maximum number of characters to include; must be &gt;= 1
* @return the text limited to {@code maxCharacters} characters; never null
* @throws NullPointerException if {@code text} is null
* @throws IllegalArgumentException if {@code maxCharacters} is less than 1
*/
public static String limit(String text, int maxCharacters) {
Objects.requireNonNull(text, "text must not be null");
if (maxCharacters < 1) {
throw new IllegalArgumentException("maxCharacters must be >= 1, but was: " + maxCharacters);
}
if (text.length() <= maxCharacters) {
return text;
}
return text.substring(0, maxCharacters);
}
}

View File

@@ -1,7 +1,10 @@
package de.gecheckt.pdf.umbenenner.application.service; package de.gecheckt.pdf.umbenenner.application.service;
import de.gecheckt.pdf.umbenenner.application.port.out.FailureCounters; import de.gecheckt.pdf.umbenenner.application.port.out.FailureCounters;
import de.gecheckt.pdf.umbenenner.domain.model.AiFunctionalFailure;
import de.gecheckt.pdf.umbenenner.domain.model.AiTechnicalFailure;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentProcessingOutcome; import de.gecheckt.pdf.umbenenner.domain.model.DocumentProcessingOutcome;
import de.gecheckt.pdf.umbenenner.domain.model.NamingProposalReady;
import de.gecheckt.pdf.umbenenner.domain.model.PreCheckFailed; import de.gecheckt.pdf.umbenenner.domain.model.PreCheckFailed;
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus; import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
import de.gecheckt.pdf.umbenenner.domain.model.TechnicalDocumentError; import de.gecheckt.pdf.umbenenner.domain.model.TechnicalDocumentError;
@@ -10,7 +13,7 @@ import de.gecheckt.pdf.umbenenner.domain.model.TechnicalDocumentError;
* Pure status and counter transition policy for document processing outcomes. * Pure status and counter transition policy for document processing outcomes.
* <p> * <p>
* This class encapsulates the deterministic rules for mapping a pipeline outcome * This class encapsulates the deterministic rules for mapping a pipeline outcome
* (success, content error, or technical error) to a processing status, updated * (pre-check, naming proposal, or failure) to a processing status, updated
* failure counters, and retryability flag. * failure counters, and retryability flag.
* <p> * <p>
* The transition logic is independent of persistence, orchestration, or any * The transition logic is independent of persistence, orchestration, or any
@@ -18,15 +21,23 @@ import de.gecheckt.pdf.umbenenner.domain.model.TechnicalDocumentError;
* *
* <h2>Transition rules</h2> * <h2>Transition rules</h2>
* <ul> * <ul>
* <li><strong>Success:</strong> Status becomes {@link ProcessingStatus#SUCCESS}, * <li><strong>Naming proposal ready:</strong> Status becomes
* counters remain unchanged, {@code retryable=false}.</li> * {@link ProcessingStatus#PROPOSAL_READY}, counters unchanged,
* <li><strong>Deterministic content error (first occurrence):</strong> * {@code retryable=false}.</li>
* <li><strong>Pre-check content error (first occurrence):</strong>
* Status becomes {@link ProcessingStatus#FAILED_RETRYABLE}, * Status becomes {@link ProcessingStatus#FAILED_RETRYABLE},
* content error counter incremented by 1, {@code retryable=true}.</li> * content error counter incremented by 1, {@code retryable=true}.</li>
* <li><strong>Deterministic content error (second or later occurrence):</strong> * <li><strong>Pre-check content error (second or later occurrence):</strong>
* Status becomes {@link ProcessingStatus#FAILED_FINAL}, * Status becomes {@link ProcessingStatus#FAILED_FINAL},
* content error counter incremented by 1, {@code retryable=false}.</li> * content error counter incremented by 1, {@code retryable=false}.</li>
* <li><strong>Technical error:</strong> Status becomes {@link ProcessingStatus#FAILED_RETRYABLE}, * <li><strong>AI functional failure (first occurrence):</strong>
* Status becomes {@link ProcessingStatus#FAILED_RETRYABLE},
* content error counter incremented by 1, {@code retryable=true}.</li>
* <li><strong>AI functional failure (second or later occurrence):</strong>
* Status becomes {@link ProcessingStatus#FAILED_FINAL},
* content error counter incremented by 1, {@code retryable=false}.</li>
* <li><strong>Technical error (pre-fingerprint / extraction / AI infrastructure):</strong>
* Status becomes {@link ProcessingStatus#FAILED_RETRYABLE},
* transient error counter incremented by 1, {@code retryable=true}.</li> * transient error counter incremented by 1, {@code retryable=true}.</li>
* </ul> * </ul>
*/ */
@@ -41,7 +52,7 @@ final class ProcessingOutcomeTransition {
* <p> * <p>
* For new documents, all failure counters start at zero. * For new documents, all failure counters start at zero.
* *
* @param pipelineOutcome the outcome from the extraction and pre-check pipeline * @param pipelineOutcome the outcome from the processing pipeline
* @return the mapped outcome with status, counters, and retryability * @return the mapped outcome with status, counters, and retryability
*/ */
static ProcessingOutcome forNewDocument(DocumentProcessingOutcome pipelineOutcome) { static ProcessingOutcome forNewDocument(DocumentProcessingOutcome pipelineOutcome) {
@@ -51,11 +62,8 @@ final class ProcessingOutcomeTransition {
/** /**
* Maps a pipeline outcome to a processing outcome, considering the existing * Maps a pipeline outcome to a processing outcome, considering the existing
* failure counter state from a known document's history. * failure counter state from a known document's history.
* <p>
* This method applies the deterministic transition rules to produce an updated
* status, counters, and retryable flag.
* *
* @param pipelineOutcome the outcome from the extraction and pre-check pipeline * @param pipelineOutcome the outcome from the processing pipeline
* @param existingCounters the current failure counter values from the document's master record * @param existingCounters the current failure counter values from the document's master record
* @return the mapped outcome with updated status, counters, and retryability * @return the mapped outcome with updated status, counters, and retryability
*/ */
@@ -64,39 +72,61 @@ final class ProcessingOutcomeTransition {
FailureCounters existingCounters) { FailureCounters existingCounters) {
return switch (pipelineOutcome) { return switch (pipelineOutcome) {
case de.gecheckt.pdf.umbenenner.domain.model.PreCheckPassed ignored -> { case NamingProposalReady ignored -> {
// Success: document passed all pre-checks // AI naming proposal produced → PROPOSAL_READY (not yet SUCCESS)
yield new ProcessingOutcome( yield new ProcessingOutcome(
ProcessingStatus.SUCCESS, ProcessingStatus.PROPOSAL_READY,
existingCounters, // counters unchanged on success existingCounters, // counters unchanged on proposal success
false // not retryable false // not retryable
); );
} }
case PreCheckFailed contentError -> { case PreCheckFailed ignored2 -> {
// Deterministic content error: apply the 1-retry rule // Deterministic content error from pre-check: apply the 1-retry rule
FailureCounters updatedCounters = existingCounters.withIncrementedContentErrorCount(); FailureCounters updatedCounters = existingCounters.withIncrementedContentErrorCount();
boolean isFirstOccurrence = existingCounters.contentErrorCount() == 0; boolean isFirstOccurrence = existingCounters.contentErrorCount() == 0;
if (isFirstOccurrence) { if (isFirstOccurrence) {
// First content error → FAILED_RETRYABLE yield new ProcessingOutcome(ProcessingStatus.FAILED_RETRYABLE, updatedCounters, true);
yield new ProcessingOutcome(
ProcessingStatus.FAILED_RETRYABLE,
updatedCounters,
true
);
} else { } else {
// Second (or later) content error → FAILED_FINAL yield new ProcessingOutcome(ProcessingStatus.FAILED_FINAL, updatedCounters, false);
yield new ProcessingOutcome(
ProcessingStatus.FAILED_FINAL,
updatedCounters,
false
);
} }
} }
case TechnicalDocumentError technicalError -> { case AiFunctionalFailure ignored3 -> {
// Technical error after fingerprinting: always FAILED_RETRYABLE, increment transient counter // Deterministic content error from AI validation: apply the 1-retry rule
FailureCounters updatedCounters = existingCounters.withIncrementedContentErrorCount();
boolean isFirstOccurrence = existingCounters.contentErrorCount() == 0;
if (isFirstOccurrence) {
yield new ProcessingOutcome(ProcessingStatus.FAILED_RETRYABLE, updatedCounters, true);
} else {
yield new ProcessingOutcome(ProcessingStatus.FAILED_FINAL, updatedCounters, false);
}
}
case TechnicalDocumentError ignored4 -> {
// Technical error (extraction / infrastructure): retryable, transient counter +1
yield new ProcessingOutcome(
ProcessingStatus.FAILED_RETRYABLE,
existingCounters.withIncrementedTransientErrorCount(),
true
);
}
case AiTechnicalFailure ignored5 -> {
// Technical AI error (timeout, unreachable, bad JSON): retryable, transient counter +1
yield new ProcessingOutcome(
ProcessingStatus.FAILED_RETRYABLE,
existingCounters.withIncrementedTransientErrorCount(),
true
);
}
case de.gecheckt.pdf.umbenenner.domain.model.PreCheckPassed ignored6 -> {
// Pre-check passed without AI step: in normal flow this should not appear at
// the outcome transition level once the AI pipeline is fully wired. Treat it
// as a technical error to avoid silent inconsistency.
yield new ProcessingOutcome( yield new ProcessingOutcome(
ProcessingStatus.FAILED_RETRYABLE, ProcessingStatus.FAILED_RETRYABLE,
existingCounters.withIncrementedTransientErrorCount(), existingCounters.withIncrementedTransientErrorCount(),

View File

@@ -0,0 +1,159 @@
package de.gecheckt.pdf.umbenenner.application.service;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
import java.time.LocalDate;
import java.util.Objects;
/**
* Stateless service for building the base target filename from a leading naming proposal.
* <p>
* The base filename follows the verbindliches Zielformat:
* <pre>
* YYYY-MM-DD - Titel.pdf
* </pre>
*
* <h2>Input source</h2>
* <p>
* The sole authoritative source for date and title is the most recent
* {@code PROPOSAL_READY} processing attempt. This service reads directly from a
* {@link ProcessingAttempt} whose
* {@link de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus#PROPOSAL_READY}
* status was confirmed by the caller.
*
* <h2>Consistency checks</h2>
* <p>
* This service does not silently heal inconsistent persistence states. If the proposal
* attempt carries a title or date that violates the rules that were enforced during
* AI response validation, the state is treated as an inconsistent persistence state
* and the caller receives an {@link InconsistentProposalState} result. Such states
* must be surfaced as document-level technical errors.
*
* <h2>No new fachliche interpretation</h2>
* <p>
* This service never re-evaluates or reinterprets the title: it uses the already-validated
* title from the proposal attempt unchanged.
*/
public final class TargetFilenameBuildingService {
private TargetFilenameBuildingService() {
// static utility, no instances
}
// -------------------------------------------------------------------------
// Result type
// -------------------------------------------------------------------------
/**
* Sealed result of {@link #buildBaseFilename(ProcessingAttempt)}.
*/
public sealed interface BaseFilenameResult
permits BaseFilenameReady, InconsistentProposalState {
}
/**
* Successful result containing the ready base filename.
*
* @param baseFilename the filename in {@code YYYY-MM-DD - Titel.pdf} format;
* never null or blank
*/
public record BaseFilenameReady(String baseFilename) implements BaseFilenameResult {
public BaseFilenameReady {
Objects.requireNonNull(baseFilename, "baseFilename must not be null");
if (baseFilename.isBlank()) {
throw new IllegalArgumentException("baseFilename must not be blank");
}
}
}
/**
* Failure result indicating that the loaded proposal attempt contains data that
* violates the rules that were applied during naming-proposal validation, making
* the persistence state inconsistent.
*
* @param reason human-readable description of the inconsistency; never null
*/
public record InconsistentProposalState(String reason) implements BaseFilenameResult {
public InconsistentProposalState {
Objects.requireNonNull(reason, "reason must not be null");
}
}
// -------------------------------------------------------------------------
// Main method
// -------------------------------------------------------------------------
/**
* Builds the base target filename from the resolved date and validated title stored
* in the given {@code PROPOSAL_READY} attempt.
* <p>
* Validation rules applied defensively (already enforced during AI response validation):
* <ul>
* <li>Resolved date must be non-null.</li>
* <li>Validated title must be non-null and non-blank.</li>
* <li>Validated title must not exceed 20 characters.</li>
* <li>Validated title must contain only letters, digits, and spaces.</li>
* </ul>
* If any rule is violated, the state is treated as an
* {@link InconsistentProposalState}.
* <p>
* The 20-character limit applies exclusively to the base title. A duplicate-avoidance
* suffix (e.g., {@code (1)}) may be appended by the target folder adapter after this
* method returns and is not counted against the 20 characters.
*
* @param proposalAttempt the leading {@code PROPOSAL_READY} attempt; must not be null
* @return a {@link BaseFilenameReady} with the complete filename, or an
* {@link InconsistentProposalState} describing the consistency violation
*/
public static BaseFilenameResult buildBaseFilename(ProcessingAttempt proposalAttempt) {
Objects.requireNonNull(proposalAttempt, "proposalAttempt must not be null");
LocalDate date = proposalAttempt.resolvedDate();
String title = proposalAttempt.validatedTitle();
if (date == null) {
return new InconsistentProposalState(
"Leading PROPOSAL_READY attempt has no resolved date");
}
if (title == null || title.isBlank()) {
return new InconsistentProposalState(
"Leading PROPOSAL_READY attempt has no validated title");
}
if (title.length() > 20) {
return new InconsistentProposalState(
"Leading PROPOSAL_READY attempt has title exceeding 20 characters: '"
+ title + "'");
}
if (!isAllowedTitleCharacters(title)) {
return new InconsistentProposalState(
"Leading PROPOSAL_READY attempt has title with disallowed characters "
+ "(only letters, digits, and spaces are permitted): '"
+ title + "'");
}
// Build: YYYY-MM-DD - Titel.pdf
String baseFilename = date + " - " + title + ".pdf";
return new BaseFilenameReady(baseFilename);
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
/**
* Returns {@code true} if every character in the title is a letter, a digit, or a space.
* Unicode letters (including German Umlauts and ß) are permitted.
*/
private static boolean isAllowedTitleCharacters(String title) {
for (int i = 0; i < title.length(); i++) {
char c = title.charAt(i);
if (!Character.isLetter(c) && !Character.isDigit(c) && c != ' ') {
return false;
}
}
return true;
}
}

View File

@@ -13,12 +13,14 @@ import de.gecheckt.pdf.umbenenner.application.port.out.RunLockPort;
import de.gecheckt.pdf.umbenenner.application.port.out.RunLockUnavailableException; import de.gecheckt.pdf.umbenenner.application.port.out.RunLockUnavailableException;
import de.gecheckt.pdf.umbenenner.application.port.out.SourceDocumentAccessException; import de.gecheckt.pdf.umbenenner.application.port.out.SourceDocumentAccessException;
import de.gecheckt.pdf.umbenenner.application.port.out.SourceDocumentCandidatesPort; import de.gecheckt.pdf.umbenenner.application.port.out.SourceDocumentCandidatesPort;
import de.gecheckt.pdf.umbenenner.application.service.AiNamingService;
import de.gecheckt.pdf.umbenenner.application.service.DocumentProcessingCoordinator; import de.gecheckt.pdf.umbenenner.application.service.DocumentProcessingCoordinator;
import de.gecheckt.pdf.umbenenner.application.service.DocumentProcessingService; import de.gecheckt.pdf.umbenenner.application.service.DocumentProcessingService;
import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext; import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint; import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentProcessingOutcome; import de.gecheckt.pdf.umbenenner.domain.model.DocumentProcessingOutcome;
import de.gecheckt.pdf.umbenenner.domain.model.PdfExtractionResult; import de.gecheckt.pdf.umbenenner.domain.model.PdfExtractionResult;
import de.gecheckt.pdf.umbenenner.domain.model.PreCheckPassed;
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate; import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate;
import java.time.Instant; import java.time.Instant;
@@ -67,13 +69,6 @@ import java.util.Objects;
* written in sequence by {@link DocumentProcessingCoordinator}. Persistence failures for a single * written in sequence by {@link DocumentProcessingCoordinator}. Persistence failures for a single
* document are caught and logged; the batch run continues with the remaining candidates. * document are caught and logged; the batch run continues with the remaining candidates.
* *
* <h2>Non-Goals (not implemented)</h2>
* <ul>
* <li>No KI/AI integration or prompt loading.</li>
* <li>No filename generation or target file copy.</li>
* <li>No retry rules for KI or target copy failures.</li>
* </ul>
*
*/ */
public class DefaultBatchRunProcessingUseCase implements BatchRunProcessingUseCase { public class DefaultBatchRunProcessingUseCase implements BatchRunProcessingUseCase {
@@ -83,6 +78,7 @@ public class DefaultBatchRunProcessingUseCase implements BatchRunProcessingUseCa
private final PdfTextExtractionPort pdfTextExtractionPort; private final PdfTextExtractionPort pdfTextExtractionPort;
private final FingerprintPort fingerprintPort; private final FingerprintPort fingerprintPort;
private final DocumentProcessingCoordinator documentProcessingCoordinator; private final DocumentProcessingCoordinator documentProcessingCoordinator;
private final AiNamingService aiNamingService;
private final ProcessingLogger logger; private final ProcessingLogger logger;
/** /**
@@ -102,6 +98,8 @@ public class DefaultBatchRunProcessingUseCase implements BatchRunProcessingUseCa
* must not be null * must not be null
* @param documentProcessingCoordinator for applying decision logic and persisting results; * @param documentProcessingCoordinator for applying decision logic and persisting results;
* must not be null * must not be null
* @param aiNamingService for running the AI naming pipeline after pre-checks;
* must not be null
* @param logger for processing-related logging; must not be null * @param logger for processing-related logging; must not be null
* @throws NullPointerException if any parameter is null * @throws NullPointerException if any parameter is null
*/ */
@@ -112,6 +110,7 @@ public class DefaultBatchRunProcessingUseCase implements BatchRunProcessingUseCa
PdfTextExtractionPort pdfTextExtractionPort, PdfTextExtractionPort pdfTextExtractionPort,
FingerprintPort fingerprintPort, FingerprintPort fingerprintPort,
DocumentProcessingCoordinator documentProcessingCoordinator, DocumentProcessingCoordinator documentProcessingCoordinator,
AiNamingService aiNamingService,
ProcessingLogger logger) { ProcessingLogger logger) {
this.runtimeConfiguration = Objects.requireNonNull(runtimeConfiguration, "runtimeConfiguration must not be null"); this.runtimeConfiguration = Objects.requireNonNull(runtimeConfiguration, "runtimeConfiguration must not be null");
this.runLockPort = Objects.requireNonNull(runLockPort, "runLockPort must not be null"); this.runLockPort = Objects.requireNonNull(runLockPort, "runLockPort must not be null");
@@ -122,6 +121,7 @@ public class DefaultBatchRunProcessingUseCase implements BatchRunProcessingUseCa
this.fingerprintPort = Objects.requireNonNull(fingerprintPort, "fingerprintPort must not be null"); this.fingerprintPort = Objects.requireNonNull(fingerprintPort, "fingerprintPort must not be null");
this.documentProcessingCoordinator = Objects.requireNonNull( this.documentProcessingCoordinator = Objects.requireNonNull(
documentProcessingCoordinator, "documentProcessingCoordinator must not be null"); documentProcessingCoordinator, "documentProcessingCoordinator must not be null");
this.aiNamingService = Objects.requireNonNull(aiNamingService, "aiNamingService must not be null");
this.logger = Objects.requireNonNull(logger, "logger must not be null"); this.logger = Objects.requireNonNull(logger, "logger must not be null");
} }
@@ -302,14 +302,24 @@ public class DefaultBatchRunProcessingUseCase implements BatchRunProcessingUseCa
} }
/** /**
* Runs the pipeline (PDF text extraction + pre-checks) for the given candidate. * Runs the full pipeline for the given candidate: extraction, pre-checks, and AI naming.
* <p> * <p>
* This method is called after a successful fingerprint computation. The result is * This method is called after a successful fingerprint computation. The result is
* passed to {@link DocumentProcessingCoordinator}, which applies it only when the document is * passed to {@link DocumentProcessingCoordinator}, which applies it only when the document is
* not in a terminal state. * not in a terminal state.
* <p>
* Processing order:
* <ol>
* <li>Extract PDF text and page count via the extraction port.</li>
* <li>Evaluate pre-checks (text quality, page limit). If any pre-check fails,
* return the failure outcome immediately — no AI call is made.</li>
* <li>If pre-checks pass, run the AI naming pipeline to obtain a naming proposal
* or classify the AI result as a technical or functional failure.</li>
* </ol>
* *
* @param candidate the candidate to run through the pipeline * @param candidate the candidate to run through the pipeline
* @return the pipeline outcome (pre-check passed, pre-check failed, or technical error) * @return the pipeline outcome; one of {@code PreCheckFailed}, {@code TechnicalDocumentError},
* {@code NamingProposalReady}, {@code AiTechnicalFailure}, or {@code AiFunctionalFailure}
*/ */
private DocumentProcessingOutcome runExtractionPipeline(SourceDocumentCandidate candidate) { private DocumentProcessingOutcome runExtractionPipeline(SourceDocumentCandidate candidate) {
PdfExtractionResult extractionResult = PdfExtractionResult extractionResult =
@@ -317,12 +327,22 @@ public class DefaultBatchRunProcessingUseCase implements BatchRunProcessingUseCa
logExtractionResult(candidate, extractionResult); logExtractionResult(candidate, extractionResult);
DocumentProcessingOutcome outcome = DocumentProcessingOutcome preCheckOutcome =
DocumentProcessingService.processDocument(candidate, extractionResult, runtimeConfiguration); DocumentProcessingService.processDocument(candidate, extractionResult, runtimeConfiguration);
logProcessingOutcome(candidate, outcome); // If pre-checks did not pass, return the failure outcome immediately.
// This avoids an AI call for documents that cannot be processed.
if (!(preCheckOutcome instanceof PreCheckPassed preCheckPassed)) {
logProcessingOutcome(candidate, preCheckOutcome);
return preCheckOutcome;
}
return outcome; // Pre-checks passed — run the AI naming pipeline
logger.info("Pre-checks passed for '{}'. Invoking AI naming pipeline.",
candidate.uniqueIdentifier());
DocumentProcessingOutcome aiOutcome = aiNamingService.invoke(preCheckPassed);
logProcessingOutcome(candidate, aiOutcome);
return aiOutcome;
} }
/** /**
@@ -361,21 +381,24 @@ public class DefaultBatchRunProcessingUseCase implements BatchRunProcessingUseCa
*/ */
private void logProcessingOutcome(SourceDocumentCandidate candidate, DocumentProcessingOutcome outcome) { private void logProcessingOutcome(SourceDocumentCandidate candidate, DocumentProcessingOutcome outcome) {
switch (outcome) { switch (outcome) {
case de.gecheckt.pdf.umbenenner.domain.model.PreCheckPassed passed -> { case de.gecheckt.pdf.umbenenner.domain.model.PreCheckFailed failed ->
logger.info("Pre-checks PASSED for '{}'. Candidate ready for persistence.",
candidate.uniqueIdentifier());
}
case de.gecheckt.pdf.umbenenner.domain.model.PreCheckFailed failed -> {
logger.info("Pre-checks FAILED for '{}': {} (Deterministic content error).", logger.info("Pre-checks FAILED for '{}': {} (Deterministic content error).",
candidate.uniqueIdentifier(), failed.failureReasonDescription()); candidate.uniqueIdentifier(), failed.failureReasonDescription());
} case de.gecheckt.pdf.umbenenner.domain.model.TechnicalDocumentError technicalError ->
case de.gecheckt.pdf.umbenenner.domain.model.TechnicalDocumentError technicalError -> {
logger.warn("Processing FAILED for '{}': {} (Technical error retryable).", logger.warn("Processing FAILED for '{}': {} (Technical error retryable).",
candidate.uniqueIdentifier(), technicalError.errorMessage()); candidate.uniqueIdentifier(), technicalError.errorMessage());
} case de.gecheckt.pdf.umbenenner.domain.model.NamingProposalReady ready ->
default -> { logger.info("AI naming proposal ready for '{}': title='{}', date={}.",
// Handle any other cases candidate.uniqueIdentifier(),
} ready.proposal().validatedTitle(),
ready.proposal().resolvedDate());
case de.gecheckt.pdf.umbenenner.domain.model.AiTechnicalFailure aiTechnical ->
logger.warn("AI technical failure for '{}': {} (Transient retryable).",
candidate.uniqueIdentifier(), aiTechnical.errorMessage());
case de.gecheckt.pdf.umbenenner.domain.model.AiFunctionalFailure aiFunctional ->
logger.info("AI functional failure for '{}': {} (Deterministic content error).",
candidate.uniqueIdentifier(), aiFunctional.errorMessage());
default -> { /* other outcomes are handled elsewhere */ }
} }
} }
} }

View File

@@ -0,0 +1,317 @@
package de.gecheckt.pdf.umbenenner.application.service;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationPort;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationSuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationTechnicalFailure;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingSuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptPort;
import de.gecheckt.pdf.umbenenner.domain.model.AiFunctionalFailure;
import de.gecheckt.pdf.umbenenner.domain.model.AiRawResponse;
import de.gecheckt.pdf.umbenenner.domain.model.AiRequestRepresentation;
import de.gecheckt.pdf.umbenenner.domain.model.AiTechnicalFailure;
import de.gecheckt.pdf.umbenenner.domain.model.DateSource;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentProcessingOutcome;
import de.gecheckt.pdf.umbenenner.domain.model.NamingProposalReady;
import de.gecheckt.pdf.umbenenner.domain.model.PdfExtractionSuccess;
import de.gecheckt.pdf.umbenenner.domain.model.PdfPageCount;
import de.gecheckt.pdf.umbenenner.domain.model.PreCheckPassed;
import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier;
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate;
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
/**
* Unit tests for {@link AiNamingService}.
* <p>
* Covers: prompt load failure, AI invocation failure, unparseable response,
* functional validation failure, and the successful naming proposal path.
*/
@ExtendWith(MockitoExtension.class)
class AiNamingServiceTest {
private static final String MODEL_NAME = "gpt-4";
private static final int MAX_CHARS = 1000;
private static final Instant FIXED_INSTANT = Instant.parse("2026-04-07T10:00:00Z");
@Mock
private AiInvocationPort aiInvocationPort;
@Mock
private PromptPort promptPort;
private AiResponseValidator validator;
private AiNamingService service;
private SourceDocumentCandidate candidate;
private PreCheckPassed preCheckPassed;
@BeforeEach
void setUp() {
validator = new AiResponseValidator(() -> FIXED_INSTANT);
service = new AiNamingService(aiInvocationPort, promptPort, validator, MODEL_NAME, MAX_CHARS);
candidate = new SourceDocumentCandidate(
"test.pdf", 1024L, new SourceDocumentLocator("/tmp/test.pdf"));
preCheckPassed = new PreCheckPassed(
candidate, new PdfExtractionSuccess("Document text content", new PdfPageCount(2)));
}
// -------------------------------------------------------------------------
// Helper
// -------------------------------------------------------------------------
private static AiRequestRepresentation dummyRequest() {
return new AiRequestRepresentation(
new PromptIdentifier("prompt.txt"), "Prompt content", "Document text", 13);
}
private static AiInvocationSuccess successWith(String jsonBody) {
return new AiInvocationSuccess(dummyRequest(), new AiRawResponse(jsonBody));
}
private static AiInvocationTechnicalFailure technicalFailure(String reason, String message) {
return new AiInvocationTechnicalFailure(dummyRequest(), reason, message);
}
// -------------------------------------------------------------------------
// Prompt load failure
// -------------------------------------------------------------------------
@Test
void invoke_promptLoadFailure_returnsAiTechnicalFailure() {
when(promptPort.loadPrompt()).thenReturn(
new PromptLoadingFailure("FILE_NOT_FOUND", "Prompt file missing"));
DocumentProcessingOutcome result = service.invoke(preCheckPassed);
assertThat(result).isInstanceOf(AiTechnicalFailure.class);
AiTechnicalFailure failure = (AiTechnicalFailure) result;
assertThat(failure.errorMessage()).contains("Prompt loading failed");
assertThat(failure.aiContext().modelName()).isEqualTo(MODEL_NAME);
}
// -------------------------------------------------------------------------
// AI invocation failure
// -------------------------------------------------------------------------
@Test
void invoke_aiInvocationTimeout_returnsAiTechnicalFailure() {
when(promptPort.loadPrompt()).thenReturn(
new PromptLoadingSuccess(new PromptIdentifier("prompt-v1.txt"), "Analyze this document."));
when(aiInvocationPort.invoke(any(AiRequestRepresentation.class))).thenReturn(
technicalFailure("TIMEOUT", "Request timed out after 30s"));
DocumentProcessingOutcome result = service.invoke(preCheckPassed);
assertThat(result).isInstanceOf(AiTechnicalFailure.class);
assertThat(((AiTechnicalFailure) result).errorMessage()).contains("TIMEOUT");
}
@Test
void invoke_aiInvocationConnectionError_returnsAiTechnicalFailure() {
when(promptPort.loadPrompt()).thenReturn(
new PromptLoadingSuccess(new PromptIdentifier("prompt.txt"), "Prompt content"));
when(aiInvocationPort.invoke(any(AiRequestRepresentation.class))).thenReturn(
technicalFailure("CONNECTION_ERROR", "Connection refused"));
DocumentProcessingOutcome result = service.invoke(preCheckPassed);
assertThat(result).isInstanceOf(AiTechnicalFailure.class);
}
// -------------------------------------------------------------------------
// Response parsing failure (unparseable JSON → technical failure)
// -------------------------------------------------------------------------
@Test
void invoke_unparseableAiResponse_returnsAiTechnicalFailure() {
when(promptPort.loadPrompt()).thenReturn(
new PromptLoadingSuccess(new PromptIdentifier("prompt.txt"), "Prompt"));
when(aiInvocationPort.invoke(any(AiRequestRepresentation.class))).thenReturn(
successWith("This is not JSON at all"));
DocumentProcessingOutcome result = service.invoke(preCheckPassed);
assertThat(result).isInstanceOf(AiTechnicalFailure.class);
assertThat(((AiTechnicalFailure) result).aiContext().aiRawResponse())
.isEqualTo("This is not JSON at all");
}
@Test
void invoke_aiResponseMissingTitle_returnsAiTechnicalFailure() {
when(promptPort.loadPrompt()).thenReturn(
new PromptLoadingSuccess(new PromptIdentifier("prompt.txt"), "Prompt"));
when(aiInvocationPort.invoke(any(AiRequestRepresentation.class))).thenReturn(
successWith("{\"reasoning\":\"No title provided\"}"));
DocumentProcessingOutcome result = service.invoke(preCheckPassed);
assertThat(result).isInstanceOf(AiTechnicalFailure.class);
}
// -------------------------------------------------------------------------
// Functional validation failure (parseable but semantically invalid)
// -------------------------------------------------------------------------
@Test
void invoke_aiResponseTitleTooLong_returnsAiFunctionalFailure() {
when(promptPort.loadPrompt()).thenReturn(
new PromptLoadingSuccess(new PromptIdentifier("prompt.txt"), "Prompt"));
// 21-char title: "TitleThatIsTooLongXXX"
when(aiInvocationPort.invoke(any(AiRequestRepresentation.class))).thenReturn(
successWith("{\"title\":\"TitleThatIsTooLongXXX\",\"reasoning\":\"Too long\",\"date\":\"2026-01-15\"}"));
DocumentProcessingOutcome result = service.invoke(preCheckPassed);
assertThat(result).isInstanceOf(AiFunctionalFailure.class);
}
@Test
void invoke_aiResponseGenericTitle_returnsAiFunctionalFailure() {
when(promptPort.loadPrompt()).thenReturn(
new PromptLoadingSuccess(new PromptIdentifier("prompt.txt"), "Prompt"));
when(aiInvocationPort.invoke(any(AiRequestRepresentation.class))).thenReturn(
successWith("{\"title\":\"Dokument\",\"reasoning\":\"Generic\"}"));
DocumentProcessingOutcome result = service.invoke(preCheckPassed);
assertThat(result).isInstanceOf(AiFunctionalFailure.class);
}
@Test
void invoke_aiResponseInvalidDateFormat_returnsAiFunctionalFailure() {
when(promptPort.loadPrompt()).thenReturn(
new PromptLoadingSuccess(new PromptIdentifier("prompt.txt"), "Prompt"));
when(aiInvocationPort.invoke(any(AiRequestRepresentation.class))).thenReturn(
successWith("{\"title\":\"Rechnung\",\"reasoning\":\"OK\",\"date\":\"15.01.2026\"}"));
DocumentProcessingOutcome result = service.invoke(preCheckPassed);
assertThat(result).isInstanceOf(AiFunctionalFailure.class);
}
// -------------------------------------------------------------------------
// Successful naming proposal
// -------------------------------------------------------------------------
@Test
void invoke_validAiResponse_returnsNamingProposalReady() {
when(promptPort.loadPrompt()).thenReturn(
new PromptLoadingSuccess(new PromptIdentifier("prompt-v1.txt"), "Analyze the document."));
when(aiInvocationPort.invoke(any(AiRequestRepresentation.class))).thenReturn(
successWith("{\"title\":\"Stromabrechnung\",\"reasoning\":\"Electricity invoice\",\"date\":\"2026-01-15\"}"));
DocumentProcessingOutcome result = service.invoke(preCheckPassed);
assertThat(result).isInstanceOf(NamingProposalReady.class);
NamingProposalReady ready = (NamingProposalReady) result;
assertThat(ready.proposal().validatedTitle()).isEqualTo("Stromabrechnung");
assertThat(ready.proposal().resolvedDate()).isEqualTo(LocalDate.of(2026, 1, 15));
assertThat(ready.proposal().dateSource()).isEqualTo(DateSource.AI_PROVIDED);
assertThat(ready.aiContext().modelName()).isEqualTo(MODEL_NAME);
assertThat(ready.aiContext().promptIdentifier()).isEqualTo("prompt-v1.txt");
}
@Test
void invoke_validAiResponseWithoutDate_usesFallbackDate() {
when(promptPort.loadPrompt()).thenReturn(
new PromptLoadingSuccess(new PromptIdentifier("prompt.txt"), "Prompt"));
when(aiInvocationPort.invoke(any(AiRequestRepresentation.class))).thenReturn(
successWith("{\"title\":\"Kontoauszug\",\"reasoning\":\"No date in document\"}"));
DocumentProcessingOutcome result = service.invoke(preCheckPassed);
assertThat(result).isInstanceOf(NamingProposalReady.class);
NamingProposalReady ready = (NamingProposalReady) result;
assertThat(ready.proposal().dateSource()).isEqualTo(DateSource.FALLBACK_CURRENT);
assertThat(ready.proposal().resolvedDate())
.isEqualTo(FIXED_INSTANT.atZone(ZoneOffset.UTC).toLocalDate());
}
@Test
void invoke_documentTextLongerThanMax_sendsLimitedText() {
// max chars is 1000, document text is 2000 chars → sent chars should be 1000
String longText = "X".repeat(2000);
PreCheckPassed longDoc = new PreCheckPassed(
candidate, new PdfExtractionSuccess(longText, new PdfPageCount(5)));
when(promptPort.loadPrompt()).thenReturn(
new PromptLoadingSuccess(new PromptIdentifier("prompt.txt"), "Prompt"));
when(aiInvocationPort.invoke(any(AiRequestRepresentation.class))).thenReturn(
successWith("{\"title\":\"Rechnung\",\"reasoning\":\"Invoice\",\"date\":\"2026-03-01\"}"));
DocumentProcessingOutcome result = service.invoke(longDoc);
assertThat(result).isInstanceOf(NamingProposalReady.class);
NamingProposalReady ready = (NamingProposalReady) result;
assertThat(ready.aiContext().sentCharacterCount()).isEqualTo(MAX_CHARS);
}
@Test
void invoke_documentTextShorterThanMax_sendsFullText() {
String shortText = "Short document";
PreCheckPassed shortDoc = new PreCheckPassed(
candidate, new PdfExtractionSuccess(shortText, new PdfPageCount(1)));
when(promptPort.loadPrompt()).thenReturn(
new PromptLoadingSuccess(new PromptIdentifier("prompt.txt"), "Prompt"));
when(aiInvocationPort.invoke(any(AiRequestRepresentation.class))).thenReturn(
successWith("{\"title\":\"Rechnung\",\"reasoning\":\"Invoice\",\"date\":\"2026-03-01\"}"));
DocumentProcessingOutcome result = service.invoke(shortDoc);
assertThat(result).isInstanceOf(NamingProposalReady.class);
NamingProposalReady ready = (NamingProposalReady) result;
assertThat(ready.aiContext().sentCharacterCount()).isEqualTo(shortText.length());
}
// -------------------------------------------------------------------------
// Null handling
// -------------------------------------------------------------------------
@Test
void invoke_nullPreCheckPassed_throwsNullPointerException() {
assertThatThrownBy(() -> service.invoke(null))
.isInstanceOf(NullPointerException.class)
.hasMessage("preCheckPassed must not be null");
}
@Test
void constructor_nullAiPort_throwsNullPointerException() {
assertThatThrownBy(() -> new AiNamingService(null, promptPort, validator, MODEL_NAME, MAX_CHARS))
.isInstanceOf(NullPointerException.class);
}
@Test
void constructor_nullPromptPort_throwsNullPointerException() {
assertThatThrownBy(() -> new AiNamingService(aiInvocationPort, null, validator, MODEL_NAME, MAX_CHARS))
.isInstanceOf(NullPointerException.class);
}
@Test
void constructor_nullValidator_throwsNullPointerException() {
assertThatThrownBy(() -> new AiNamingService(aiInvocationPort, promptPort, null, MODEL_NAME, MAX_CHARS))
.isInstanceOf(NullPointerException.class);
}
@Test
void constructor_maxTextCharactersZero_throwsIllegalArgumentException() {
assertThatThrownBy(() -> new AiNamingService(aiInvocationPort, promptPort, validator, MODEL_NAME, 0))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("maxTextCharacters must be >= 1");
}
}

View File

@@ -0,0 +1,217 @@
package de.gecheckt.pdf.umbenenner.application.service;
import de.gecheckt.pdf.umbenenner.domain.model.AiRawResponse;
import de.gecheckt.pdf.umbenenner.domain.model.AiResponseParsingFailure;
import de.gecheckt.pdf.umbenenner.domain.model.AiResponseParsingResult;
import de.gecheckt.pdf.umbenenner.domain.model.AiResponseParsingSuccess;
import de.gecheckt.pdf.umbenenner.domain.model.ParsedAiResponse;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Unit tests for {@link AiResponseParser}.
* <p>
* Covers structural parsing rules: valid JSON objects, mandatory fields,
* optional date, extra fields, and rejection of non-JSON or mixed-content responses.
*/
class AiResponseParserTest {
// -------------------------------------------------------------------------
// Success cases
// -------------------------------------------------------------------------
@Test
void parse_validJsonWithAllFields_returnsSuccess() {
AiRawResponse raw = new AiRawResponse(
"{\"title\":\"Stromabrechnung\",\"reasoning\":\"Found bill dated 2026-01-15\",\"date\":\"2026-01-15\"}");
AiResponseParsingResult result = AiResponseParser.parse(raw);
assertThat(result).isInstanceOf(AiResponseParsingSuccess.class);
ParsedAiResponse parsed = ((AiResponseParsingSuccess) result).response();
assertThat(parsed.title()).isEqualTo("Stromabrechnung");
assertThat(parsed.reasoning()).isEqualTo("Found bill dated 2026-01-15");
assertThat(parsed.dateString()).contains("2026-01-15");
}
@Test
void parse_validJsonWithoutDate_returnsSuccessWithEmptyOptional() {
AiRawResponse raw = new AiRawResponse(
"{\"title\":\"Kontoauszug\",\"reasoning\":\"No date found in document\"}");
AiResponseParsingResult result = AiResponseParser.parse(raw);
assertThat(result).isInstanceOf(AiResponseParsingSuccess.class);
ParsedAiResponse parsed = ((AiResponseParsingSuccess) result).response();
assertThat(parsed.title()).isEqualTo("Kontoauszug");
assertThat(parsed.dateString()).isEmpty();
}
@Test
void parse_validJsonWithAdditionalFields_toleratesExtraFields() {
AiRawResponse raw = new AiRawResponse(
"{\"title\":\"Rechnung\",\"reasoning\":\"Invoice\",\"confidence\":0.95,\"lang\":\"de\"}");
AiResponseParsingResult result = AiResponseParser.parse(raw);
assertThat(result).isInstanceOf(AiResponseParsingSuccess.class);
ParsedAiResponse parsed = ((AiResponseParsingSuccess) result).response();
assertThat(parsed.title()).isEqualTo("Rechnung");
}
@Test
void parse_validJsonWithLeadingAndTrailingWhitespace_trimsAndSucceeds() {
AiRawResponse raw = new AiRawResponse(
" {\"title\":\"Vertrag\",\"reasoning\":\"Contract document\"} ");
AiResponseParsingResult result = AiResponseParser.parse(raw);
assertThat(result).isInstanceOf(AiResponseParsingSuccess.class);
}
@Test
void parse_emptyReasoningField_isAccepted() {
AiRawResponse raw = new AiRawResponse(
"{\"title\":\"Mahnung\",\"reasoning\":\"\"}");
AiResponseParsingResult result = AiResponseParser.parse(raw);
assertThat(result).isInstanceOf(AiResponseParsingSuccess.class);
ParsedAiResponse parsed = ((AiResponseParsingSuccess) result).response();
assertThat(parsed.reasoning()).isEmpty();
}
@Test
void parse_nullDateField_treatedAsAbsent() {
AiRawResponse raw = new AiRawResponse(
"{\"title\":\"Bescheid\",\"reasoning\":\"Administrative notice\",\"date\":null}");
AiResponseParsingResult result = AiResponseParser.parse(raw);
assertThat(result).isInstanceOf(AiResponseParsingSuccess.class);
ParsedAiResponse parsed = ((AiResponseParsingSuccess) result).response();
assertThat(parsed.dateString()).isEmpty();
}
// -------------------------------------------------------------------------
// Failure cases structural
// -------------------------------------------------------------------------
@Test
void parse_emptyBody_returnsFailure() {
AiRawResponse raw = new AiRawResponse("");
AiResponseParsingResult result = AiResponseParser.parse(raw);
assertThat(result).isInstanceOf(AiResponseParsingFailure.class);
assertThat(((AiResponseParsingFailure) result).failureReason())
.isEqualTo("EMPTY_RESPONSE");
}
@Test
void parse_blankBody_returnsFailure() {
AiRawResponse raw = new AiRawResponse(" \t\n ");
AiResponseParsingResult result = AiResponseParser.parse(raw);
assertThat(result).isInstanceOf(AiResponseParsingFailure.class);
}
@Test
void parse_plainText_returnsFailure() {
AiRawResponse raw = new AiRawResponse("Sure, here is the title: Rechnung");
AiResponseParsingResult result = AiResponseParser.parse(raw);
assertThat(result).isInstanceOf(AiResponseParsingFailure.class);
assertThat(((AiResponseParsingFailure) result).failureReason())
.isEqualTo("NOT_JSON_OBJECT");
}
@Test
void parse_jsonEmbeddedInProse_returnsFailure() {
AiRawResponse raw = new AiRawResponse(
"Here is the result: {\"title\":\"Rechnung\",\"reasoning\":\"r\"} Hope that helps!");
AiResponseParsingResult result = AiResponseParser.parse(raw);
assertThat(result).isInstanceOf(AiResponseParsingFailure.class);
assertThat(((AiResponseParsingFailure) result).failureReason())
.isEqualTo("NOT_JSON_OBJECT");
}
@Test
void parse_jsonArray_returnsFailure() {
AiRawResponse raw = new AiRawResponse("[{\"title\":\"Rechnung\",\"reasoning\":\"r\"}]");
AiResponseParsingResult result = AiResponseParser.parse(raw);
assertThat(result).isInstanceOf(AiResponseParsingFailure.class);
assertThat(((AiResponseParsingFailure) result).failureReason())
.isEqualTo("NOT_JSON_OBJECT");
}
@Test
void parse_invalidJson_returnsFailure() {
AiRawResponse raw = new AiRawResponse("{\"title\":\"Rechnung\",\"reasoning\":}");
AiResponseParsingResult result = AiResponseParser.parse(raw);
assertThat(result).isInstanceOf(AiResponseParsingFailure.class);
assertThat(((AiResponseParsingFailure) result).failureReason())
.isEqualTo("INVALID_JSON");
}
@Test
void parse_missingTitle_returnsFailure() {
AiRawResponse raw = new AiRawResponse("{\"reasoning\":\"Some reasoning without title\"}");
AiResponseParsingResult result = AiResponseParser.parse(raw);
assertThat(result).isInstanceOf(AiResponseParsingFailure.class);
assertThat(((AiResponseParsingFailure) result).failureReason())
.isEqualTo("MISSING_TITLE");
}
@Test
void parse_nullTitle_returnsFailure() {
AiRawResponse raw = new AiRawResponse("{\"title\":null,\"reasoning\":\"r\"}");
AiResponseParsingResult result = AiResponseParser.parse(raw);
assertThat(result).isInstanceOf(AiResponseParsingFailure.class);
assertThat(((AiResponseParsingFailure) result).failureReason())
.isEqualTo("MISSING_TITLE");
}
@Test
void parse_blankTitle_returnsFailure() {
AiRawResponse raw = new AiRawResponse("{\"title\":\" \",\"reasoning\":\"r\"}");
AiResponseParsingResult result = AiResponseParser.parse(raw);
assertThat(result).isInstanceOf(AiResponseParsingFailure.class);
assertThat(((AiResponseParsingFailure) result).failureReason())
.isEqualTo("BLANK_TITLE");
}
@Test
void parse_missingReasoning_returnsFailure() {
AiRawResponse raw = new AiRawResponse("{\"title\":\"Rechnung\"}");
AiResponseParsingResult result = AiResponseParser.parse(raw);
assertThat(result).isInstanceOf(AiResponseParsingFailure.class);
assertThat(((AiResponseParsingFailure) result).failureReason())
.isEqualTo("MISSING_REASONING");
}
@Test
void parse_nullRawResponse_throwsNullPointerException() {
assertThatThrownBy(() -> AiResponseParser.parse(null))
.isInstanceOf(NullPointerException.class)
.hasMessage("rawResponse must not be null");
}
}

View File

@@ -0,0 +1,236 @@
package de.gecheckt.pdf.umbenenner.application.service;
import de.gecheckt.pdf.umbenenner.application.port.out.ClockPort;
import de.gecheckt.pdf.umbenenner.domain.model.DateSource;
import de.gecheckt.pdf.umbenenner.domain.model.NamingProposal;
import de.gecheckt.pdf.umbenenner.domain.model.ParsedAiResponse;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Unit tests for {@link AiResponseValidator}.
* <p>
* Covers: title character rules, length limit, generic placeholder detection,
* date parsing, date fallback via {@link ClockPort}, and null handling.
*/
class AiResponseValidatorTest {
private static final Instant FIXED_INSTANT = Instant.parse("2026-04-07T10:00:00Z");
private static final LocalDate FIXED_DATE = FIXED_INSTANT.atZone(ZoneOffset.UTC).toLocalDate();
private AiResponseValidator validator;
@BeforeEach
void setUp() {
ClockPort fixedClock = () -> FIXED_INSTANT;
validator = new AiResponseValidator(fixedClock);
}
// -------------------------------------------------------------------------
// Valid cases
// -------------------------------------------------------------------------
@Test
void validate_validTitleAndAiDate_returnsValidWithAiProvided() {
ParsedAiResponse parsed = ParsedAiResponse.of("Stromabrechnung", "Electricity bill", "2026-01-15");
AiResponseValidator.AiValidationResult result = validator.validate(parsed);
assertThat(result).isInstanceOf(AiResponseValidator.AiValidationResult.Valid.class);
NamingProposal proposal = ((AiResponseValidator.AiValidationResult.Valid) result).proposal();
assertThat(proposal.validatedTitle()).isEqualTo("Stromabrechnung");
assertThat(proposal.resolvedDate()).isEqualTo(LocalDate.of(2026, 1, 15));
assertThat(proposal.dateSource()).isEqualTo(DateSource.AI_PROVIDED);
assertThat(proposal.aiReasoning()).isEqualTo("Electricity bill");
}
@Test
void validate_validTitleNoDate_usesFallbackCurrentDate() {
ParsedAiResponse parsed = ParsedAiResponse.of("Kontoauszug", "No date in document", null);
AiResponseValidator.AiValidationResult result = validator.validate(parsed);
assertThat(result).isInstanceOf(AiResponseValidator.AiValidationResult.Valid.class);
NamingProposal proposal = ((AiResponseValidator.AiValidationResult.Valid) result).proposal();
assertThat(proposal.resolvedDate()).isEqualTo(FIXED_DATE);
assertThat(proposal.dateSource()).isEqualTo(DateSource.FALLBACK_CURRENT);
}
@Test
void validate_titleWithUmlauts_isAccepted() {
ParsedAiResponse parsed = ParsedAiResponse.of("Mietvertrag Müller", "Rental contract", null);
AiResponseValidator.AiValidationResult result = validator.validate(parsed);
assertThat(result).isInstanceOf(AiResponseValidator.AiValidationResult.Valid.class);
}
@Test
void validate_titleWithSzligChar_isAccepted() {
ParsedAiResponse parsed = ParsedAiResponse.of("Straßenrechnung", "Street bill", null);
AiResponseValidator.AiValidationResult result = validator.validate(parsed);
assertThat(result).isInstanceOf(AiResponseValidator.AiValidationResult.Valid.class);
}
@Test
void validate_titleWithDigits_isAccepted() {
ParsedAiResponse parsed = ParsedAiResponse.of("Rechnung 2026", "Invoice 2026", null);
AiResponseValidator.AiValidationResult result = validator.validate(parsed);
assertThat(result).isInstanceOf(AiResponseValidator.AiValidationResult.Valid.class);
}
@Test
void validate_titleExactly20Chars_isAccepted() {
String title = "12345678901234567890"; // exactly 20 chars
ParsedAiResponse parsed = ParsedAiResponse.of(title, "test", null);
AiResponseValidator.AiValidationResult result = validator.validate(parsed);
assertThat(result).isInstanceOf(AiResponseValidator.AiValidationResult.Valid.class);
}
@Test
void validate_emptyReasoning_isAccepted() {
ParsedAiResponse parsed = ParsedAiResponse.of("Rechnung", "", null);
AiResponseValidator.AiValidationResult result = validator.validate(parsed);
assertThat(result).isInstanceOf(AiResponseValidator.AiValidationResult.Valid.class);
}
// -------------------------------------------------------------------------
// Title validation failures
// -------------------------------------------------------------------------
@Test
void validate_title21Chars_returnsInvalid() {
String title = "1234567890123456789A1"; // 21 chars
ParsedAiResponse parsed = ParsedAiResponse.of(title, "reasoning", null);
AiResponseValidator.AiValidationResult result = validator.validate(parsed);
assertThat(result).isInstanceOf(AiResponseValidator.AiValidationResult.Invalid.class);
assertThat(((AiResponseValidator.AiValidationResult.Invalid) result).errorMessage())
.contains("20");
}
@Test
void validate_titleWithSpecialChar_returnsInvalid() {
ParsedAiResponse parsed = ParsedAiResponse.of("Rechnung!", "reasoning", null);
AiResponseValidator.AiValidationResult result = validator.validate(parsed);
assertThat(result).isInstanceOf(AiResponseValidator.AiValidationResult.Invalid.class);
assertThat(((AiResponseValidator.AiValidationResult.Invalid) result).errorMessage())
.containsIgnoringCase("disallowed");
}
@Test
void validate_titleWithHyphen_returnsInvalid() {
ParsedAiResponse parsed = ParsedAiResponse.of("Strom-Rechnung", "reasoning", null);
AiResponseValidator.AiValidationResult result = validator.validate(parsed);
assertThat(result).isInstanceOf(AiResponseValidator.AiValidationResult.Invalid.class);
}
@Test
void validate_genericTitleDokument_returnsInvalid() {
ParsedAiResponse parsed = ParsedAiResponse.of("Dokument", "reasoning", null);
AiResponseValidator.AiValidationResult result = validator.validate(parsed);
assertThat(result).isInstanceOf(AiResponseValidator.AiValidationResult.Invalid.class);
assertThat(((AiResponseValidator.AiValidationResult.Invalid) result).errorMessage())
.containsIgnoringCase("placeholder");
}
@Test
void validate_genericTitleDateiCaseInsensitive_returnsInvalid() {
ParsedAiResponse parsed = ParsedAiResponse.of("DATEI", "reasoning", null);
AiResponseValidator.AiValidationResult result = validator.validate(parsed);
assertThat(result).isInstanceOf(AiResponseValidator.AiValidationResult.Invalid.class);
}
@Test
void validate_genericTitleScan_returnsInvalid() {
ParsedAiResponse parsed = ParsedAiResponse.of("scan", "reasoning", null);
AiResponseValidator.AiValidationResult result = validator.validate(parsed);
assertThat(result).isInstanceOf(AiResponseValidator.AiValidationResult.Invalid.class);
}
@Test
void validate_genericTitlePdf_returnsInvalid() {
ParsedAiResponse parsed = ParsedAiResponse.of("PDF", "reasoning", null);
AiResponseValidator.AiValidationResult result = validator.validate(parsed);
assertThat(result).isInstanceOf(AiResponseValidator.AiValidationResult.Invalid.class);
}
// -------------------------------------------------------------------------
// Date validation failures
// -------------------------------------------------------------------------
@Test
void validate_aiProvidesUnparseableDate_returnsInvalid() {
ParsedAiResponse parsed = ParsedAiResponse.of("Rechnung", "reasoning", "not-a-date");
AiResponseValidator.AiValidationResult result = validator.validate(parsed);
assertThat(result).isInstanceOf(AiResponseValidator.AiValidationResult.Invalid.class);
assertThat(((AiResponseValidator.AiValidationResult.Invalid) result).errorMessage())
.contains("not-a-date");
}
@Test
void validate_aiProvidesWrongDateFormat_returnsInvalid() {
ParsedAiResponse parsed = ParsedAiResponse.of("Rechnung", "reasoning", "15.01.2026");
AiResponseValidator.AiValidationResult result = validator.validate(parsed);
assertThat(result).isInstanceOf(AiResponseValidator.AiValidationResult.Invalid.class);
}
@Test
void validate_aiProvidesPartialDate_returnsInvalid() {
ParsedAiResponse parsed = ParsedAiResponse.of("Rechnung", "reasoning", "2026-01");
AiResponseValidator.AiValidationResult result = validator.validate(parsed);
assertThat(result).isInstanceOf(AiResponseValidator.AiValidationResult.Invalid.class);
}
// -------------------------------------------------------------------------
// Null handling
// -------------------------------------------------------------------------
@Test
void validate_nullParsedResponse_throwsNullPointerException() {
assertThatThrownBy(() -> validator.validate(null))
.isInstanceOf(NullPointerException.class)
.hasMessage("parsed must not be null");
}
@Test
void constructor_nullClockPort_throwsNullPointerException() {
assertThatThrownBy(() -> new AiResponseValidator(null))
.isInstanceOf(NullPointerException.class)
.hasMessage("clockPort must not be null");
}
}

View File

@@ -13,10 +13,22 @@ import de.gecheckt.pdf.umbenenner.application.port.out.PersistenceLookupTechnica
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt; import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttemptRepository; import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttemptRepository;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingLogger; import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingLogger;
import de.gecheckt.pdf.umbenenner.application.port.out.ResolvedTargetFilename;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopyPort;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopyResult;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopySuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopyTechnicalFailure;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFilenameResolutionResult;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFolderPort;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFolderTechnicalFailure;
import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort; import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort;
import de.gecheckt.pdf.umbenenner.domain.model.AiAttemptContext;
import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext; import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext;
import de.gecheckt.pdf.umbenenner.domain.model.DateSource;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint; import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentProcessingOutcome; import de.gecheckt.pdf.umbenenner.domain.model.DocumentProcessingOutcome;
import de.gecheckt.pdf.umbenenner.domain.model.NamingProposal;
import de.gecheckt.pdf.umbenenner.domain.model.NamingProposalReady;
import de.gecheckt.pdf.umbenenner.domain.model.PdfExtractionSuccess; import de.gecheckt.pdf.umbenenner.domain.model.PdfExtractionSuccess;
import de.gecheckt.pdf.umbenenner.domain.model.PdfPageCount; import de.gecheckt.pdf.umbenenner.domain.model.PdfPageCount;
import de.gecheckt.pdf.umbenenner.domain.model.PreCheckFailed; import de.gecheckt.pdf.umbenenner.domain.model.PreCheckFailed;
@@ -32,6 +44,7 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.function.Consumer; import java.util.function.Consumer;
@@ -72,7 +85,8 @@ class DocumentProcessingCoordinatorTest {
recordRepo = new CapturingDocumentRecordRepository(); recordRepo = new CapturingDocumentRecordRepository();
attemptRepo = new CapturingProcessingAttemptRepository(); attemptRepo = new CapturingProcessingAttemptRepository();
unitOfWorkPort = new CapturingUnitOfWorkPort(recordRepo, attemptRepo); unitOfWorkPort = new CapturingUnitOfWorkPort(recordRepo, attemptRepo);
processor = new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, new NoOpProcessingLogger()); processor = new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger());
candidate = new SourceDocumentCandidate( candidate = new SourceDocumentCandidate(
"test.pdf", 1024L, new SourceDocumentLocator("/tmp/test.pdf")); "test.pdf", 1024L, new SourceDocumentLocator("/tmp/test.pdf"));
@@ -86,17 +100,16 @@ class DocumentProcessingCoordinatorTest {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@Test @Test
void process_newDocument_preCheckPassed_persistsSuccessStatus() { void process_newDocument_namingProposalReady_persistsProposalReadyStatus() {
recordRepo.setLookupResult(new DocumentUnknown()); recordRepo.setLookupResult(new DocumentUnknown());
DocumentProcessingOutcome outcome = new PreCheckPassed( DocumentProcessingOutcome outcome = buildNamingProposalOutcome();
candidate, new PdfExtractionSuccess("text", new PdfPageCount(1)));
processor.process(candidate, fingerprint, outcome, context, attemptStart); processor.process(candidate, fingerprint, outcome, context, attemptStart);
// One attempt written // One attempt written
assertEquals(1, attemptRepo.savedAttempts.size()); assertEquals(1, attemptRepo.savedAttempts.size());
ProcessingAttempt attempt = attemptRepo.savedAttempts.get(0); ProcessingAttempt attempt = attemptRepo.savedAttempts.get(0);
assertEquals(ProcessingStatus.SUCCESS, attempt.status()); assertEquals(ProcessingStatus.PROPOSAL_READY, attempt.status());
assertFalse(attempt.retryable()); assertFalse(attempt.retryable());
assertNull(attempt.failureClass()); assertNull(attempt.failureClass());
assertNull(attempt.failureMessage()); assertNull(attempt.failureMessage());
@@ -104,10 +117,11 @@ class DocumentProcessingCoordinatorTest {
// One master record created // One master record created
assertEquals(1, recordRepo.createdRecords.size()); assertEquals(1, recordRepo.createdRecords.size());
DocumentRecord record = recordRepo.createdRecords.get(0); DocumentRecord record = recordRepo.createdRecords.get(0);
assertEquals(ProcessingStatus.SUCCESS, record.overallStatus()); assertEquals(ProcessingStatus.PROPOSAL_READY, record.overallStatus());
assertEquals(0, record.failureCounters().contentErrorCount()); assertEquals(0, record.failureCounters().contentErrorCount());
assertEquals(0, record.failureCounters().transientErrorCount()); assertEquals(0, record.failureCounters().transientErrorCount());
assertNotNull(record.lastSuccessInstant()); // lastSuccessInstant is null in M5; it is set by the target-copy stage (M6)
assertNull(record.lastSuccessInstant());
assertNull(record.lastFailureInstant()); assertNull(record.lastFailureInstant());
} }
@@ -203,24 +217,24 @@ class DocumentProcessingCoordinatorTest {
} }
@Test @Test
void process_knownDocument_preCheckPassed_persistsSuccess() { void process_knownDocument_namingProposalReady_persistsProposalReadyStatus() {
DocumentRecord existingRecord = buildRecord( DocumentRecord existingRecord = buildRecord(
ProcessingStatus.FAILED_RETRYABLE, ProcessingStatus.FAILED_RETRYABLE,
new FailureCounters(0, 1)); new FailureCounters(0, 1));
recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord)); recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
DocumentProcessingOutcome outcome = new PreCheckPassed( DocumentProcessingOutcome outcome = buildNamingProposalOutcome();
candidate, new PdfExtractionSuccess("text", new PdfPageCount(1)));
processor.process(candidate, fingerprint, outcome, context, attemptStart); processor.process(candidate, fingerprint, outcome, context, attemptStart);
assertEquals(1, recordRepo.updatedRecords.size()); assertEquals(1, recordRepo.updatedRecords.size());
DocumentRecord record = recordRepo.updatedRecords.get(0); DocumentRecord record = recordRepo.updatedRecords.get(0);
assertEquals(ProcessingStatus.SUCCESS, record.overallStatus()); assertEquals(ProcessingStatus.PROPOSAL_READY, record.overallStatus());
// Counters unchanged on success // Counters unchanged on naming proposal success
assertEquals(0, record.failureCounters().contentErrorCount()); assertEquals(0, record.failureCounters().contentErrorCount());
assertEquals(1, record.failureCounters().transientErrorCount()); assertEquals(1, record.failureCounters().transientErrorCount());
assertNotNull(record.lastSuccessInstant()); // lastSuccessInstant is null in M5; it is set by the target-copy stage (M6)
assertNull(record.lastSuccessInstant());
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -469,8 +483,7 @@ class DocumentProcessingCoordinatorTest {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@Test @Test
void process_newDocument_firstContentError_failureMessageContainsContentErrorCount() { void process_newDocument_firstContentError_failureMessageContainsFailureReason() {
// Prüft, dass die Fehlermeldung die Fehleranzahl enthält (nicht leer ist)
recordRepo.setLookupResult(new DocumentUnknown()); recordRepo.setLookupResult(new DocumentUnknown());
DocumentProcessingOutcome outcome = new PreCheckFailed( DocumentProcessingOutcome outcome = new PreCheckFailed(
candidate, PreCheckFailureReason.NO_USABLE_TEXT); candidate, PreCheckFailureReason.NO_USABLE_TEXT);
@@ -481,13 +494,13 @@ class DocumentProcessingCoordinatorTest {
assertNotNull(attempt.failureMessage(), "Fehlermeldung darf nicht null sein bei FAILED_RETRYABLE"); assertNotNull(attempt.failureMessage(), "Fehlermeldung darf nicht null sein bei FAILED_RETRYABLE");
assertFalse(attempt.failureMessage().isBlank(), assertFalse(attempt.failureMessage().isBlank(),
"Fehlermeldung darf nicht leer sein bei FAILED_RETRYABLE"); "Fehlermeldung darf nicht leer sein bei FAILED_RETRYABLE");
assertTrue(attempt.failureMessage().contains("ContentErrors=1"), assertTrue(attempt.failureMessage().contains("No usable text in extracted PDF content"),
"Fehlermeldung muss den Inhaltsfehler-Zähler enthalten: " + attempt.failureMessage()); "Fehlermeldung muss den Fehlergrund enthalten: " + attempt.failureMessage());
} }
@Test @Test
void process_knownDocument_secondContentError_failureMessageContainsFinalStatus() { void process_knownDocument_secondContentError_failureMessageContainsFinalStatus() {
// Prüft, dass die Fehlermeldung bei FAILED_FINAL den Endzustand enthält // Prüft, dass die Fehlermeldung bei FAILED_FINAL den Fehlergrund enthält
DocumentRecord existingRecord = buildRecord(ProcessingStatus.FAILED_RETRYABLE, new FailureCounters(1, 0)); DocumentRecord existingRecord = buildRecord(ProcessingStatus.FAILED_RETRYABLE, new FailureCounters(1, 0));
recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord)); recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
DocumentProcessingOutcome outcome = new PreCheckFailed( DocumentProcessingOutcome outcome = new PreCheckFailed(
@@ -499,13 +512,12 @@ class DocumentProcessingCoordinatorTest {
assertNotNull(attempt.failureMessage(), "Fehlermeldung darf nicht null sein bei FAILED_FINAL"); assertNotNull(attempt.failureMessage(), "Fehlermeldung darf nicht null sein bei FAILED_FINAL");
assertFalse(attempt.failureMessage().isBlank(), assertFalse(attempt.failureMessage().isBlank(),
"Fehlermeldung darf nicht leer sein bei FAILED_FINAL"); "Fehlermeldung darf nicht leer sein bei FAILED_FINAL");
assertTrue(attempt.failureMessage().contains("ContentErrors=2"), assertTrue(attempt.failureMessage().contains("Document page count exceeds configured limit"),
"Fehlermeldung muss den aktualisierten Inhaltsfehler-Zähler enthalten: " + attempt.failureMessage()); "Fehlermeldung muss den Fehlergrund enthalten: " + attempt.failureMessage());
} }
@Test @Test
void process_newDocument_technicalError_failureMessageContainsTransientErrorCount() { void process_newDocument_technicalError_failureMessageContainsTechnicalDetail() {
// Prüft, dass die Fehlermeldung bei transientem Fehler den Transient-Zähler enthält
recordRepo.setLookupResult(new DocumentUnknown()); recordRepo.setLookupResult(new DocumentUnknown());
DocumentProcessingOutcome outcome = new TechnicalDocumentError(candidate, "Timeout", null); DocumentProcessingOutcome outcome = new TechnicalDocumentError(candidate, "Timeout", null);
@@ -513,22 +525,21 @@ class DocumentProcessingCoordinatorTest {
ProcessingAttempt attempt = attemptRepo.savedAttempts.get(0); ProcessingAttempt attempt = attemptRepo.savedAttempts.get(0);
assertNotNull(attempt.failureMessage()); assertNotNull(attempt.failureMessage());
assertTrue(attempt.failureMessage().contains("TransientErrors=1"), assertTrue(attempt.failureMessage().contains("Timeout"),
"Fehlermeldung muss den Transient-Fehler-Zähler enthalten: " + attempt.failureMessage()); "Fehlermeldung muss den technischen Fehlerdetail enthalten: " + attempt.failureMessage());
} }
@Test @Test
void process_newDocument_preCheckPassed_failureClassAndMessageAreNull() { void process_newDocument_namingProposalReady_failureClassAndMessageAreNull() {
// Prüft, dass bei Erfolg failureClass und failureMessage null sind // Prüft, dass bei PROPOSAL_READY failureClass und failureMessage null sind
recordRepo.setLookupResult(new DocumentUnknown()); recordRepo.setLookupResult(new DocumentUnknown());
DocumentProcessingOutcome outcome = new PreCheckPassed( DocumentProcessingOutcome outcome = buildNamingProposalOutcome();
candidate, new PdfExtractionSuccess("text", new PdfPageCount(1)));
processor.process(candidate, fingerprint, outcome, context, attemptStart); processor.process(candidate, fingerprint, outcome, context, attemptStart);
ProcessingAttempt attempt = attemptRepo.savedAttempts.get(0); ProcessingAttempt attempt = attemptRepo.savedAttempts.get(0);
assertNull(attempt.failureClass(), "Bei Erfolg muss failureClass null sein"); assertNull(attempt.failureClass(), "Bei PROPOSAL_READY muss failureClass null sein");
assertNull(attempt.failureMessage(), "Bei Erfolg muss failureMessage null sein"); assertNull(attempt.failureMessage(), "Bei PROPOSAL_READY muss failureMessage null sein");
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -536,9 +547,9 @@ class DocumentProcessingCoordinatorTest {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@Test @Test
void process_knownDocument_preCheckPassed_lastSuccessInstantSetAndLastFailureInstantFromPreviousRecord() { void process_knownDocument_namingProposalReady_lastSuccessInstantNullAndLastFailureInstantFromPreviousRecord() {
// Prüft, dass bei SUCCESS am known-Dokument lastSuccessInstant gesetzt // Prüft, dass bei PROPOSAL_READY am known-Dokument lastSuccessInstant null bleibt
// und lastFailureInstant aus dem Vorgänger-Datensatz übernommen wird // (M6 setzt ihn erst nach der Zielkopie) und lastFailureInstant aus dem Vorgänger übernommen wird
Instant previousFailureInstant = Instant.parse("2025-01-15T10:00:00Z"); Instant previousFailureInstant = Instant.parse("2025-01-15T10:00:00Z");
DocumentRecord existingRecord = new DocumentRecord( DocumentRecord existingRecord = new DocumentRecord(
fingerprint, fingerprint,
@@ -549,19 +560,20 @@ class DocumentProcessingCoordinatorTest {
previousFailureInstant, // lastFailureInstant vorhanden previousFailureInstant, // lastFailureInstant vorhanden
null, // noch kein Erfolgszeitpunkt null, // noch kein Erfolgszeitpunkt
Instant.now(), Instant.now(),
Instant.now() Instant.now(),
null,
null
); );
recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord)); recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
DocumentProcessingOutcome outcome = new PreCheckPassed( DocumentProcessingOutcome outcome = buildNamingProposalOutcome();
candidate, new PdfExtractionSuccess("text", new PdfPageCount(1)));
processor.process(candidate, fingerprint, outcome, context, attemptStart); processor.process(candidate, fingerprint, outcome, context, attemptStart);
DocumentRecord updated = recordRepo.updatedRecords.get(0); DocumentRecord updated = recordRepo.updatedRecords.get(0);
assertNotNull(updated.lastSuccessInstant(), assertNull(updated.lastSuccessInstant(),
"lastSuccessInstant muss nach erfolgreichem Verarbeiten gesetzt sein"); "lastSuccessInstant muss nach PROPOSAL_READY null bleiben (wird erst von M6 gesetzt)");
assertEquals(previousFailureInstant, updated.lastFailureInstant(), assertEquals(previousFailureInstant, updated.lastFailureInstant(),
"lastFailureInstant muss bei SUCCESS den Vorgänger-Wert beibehalten"); "lastFailureInstant muss bei PROPOSAL_READY den Vorgänger-Wert beibehalten");
} }
@Test @Test
@@ -578,7 +590,9 @@ class DocumentProcessingCoordinatorTest {
null, // noch keine Fehlzeit null, // noch keine Fehlzeit
previousSuccessInstant, // vorheriger Erfolg vorhanden previousSuccessInstant, // vorheriger Erfolg vorhanden
Instant.now(), Instant.now(),
Instant.now() Instant.now(),
null,
null
); );
recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord)); recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
DocumentProcessingOutcome outcome = new PreCheckFailed( DocumentProcessingOutcome outcome = new PreCheckFailed(
@@ -602,7 +616,8 @@ class DocumentProcessingCoordinatorTest {
// Prüft, dass bei Lookup-Fehler ein Fehler-Log-Eintrag erzeugt wird // Prüft, dass bei Lookup-Fehler ein Fehler-Log-Eintrag erzeugt wird
CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger(); CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger();
DocumentProcessingCoordinator coordinatorWithCapturingLogger = DocumentProcessingCoordinator coordinatorWithCapturingLogger =
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, capturingLogger); new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger);
recordRepo.setLookupResult(new PersistenceLookupTechnicalFailure("Datenbank nicht erreichbar", null)); recordRepo.setLookupResult(new PersistenceLookupTechnicalFailure("Datenbank nicht erreichbar", null));
DocumentProcessingOutcome outcome = new PreCheckPassed( DocumentProcessingOutcome outcome = new PreCheckPassed(
candidate, new PdfExtractionSuccess("text", new PdfPageCount(1))); candidate, new PdfExtractionSuccess("text", new PdfPageCount(1)));
@@ -618,7 +633,8 @@ class DocumentProcessingCoordinatorTest {
// Prüft, dass beim Überspringen eines bereits erfolgreich verarbeiteten Dokuments geloggt wird // Prüft, dass beim Überspringen eines bereits erfolgreich verarbeiteten Dokuments geloggt wird
CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger(); CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger();
DocumentProcessingCoordinator coordinatorWithCapturingLogger = DocumentProcessingCoordinator coordinatorWithCapturingLogger =
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, capturingLogger); new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger);
DocumentRecord existingRecord = buildRecord(ProcessingStatus.SUCCESS, FailureCounters.zero()); DocumentRecord existingRecord = buildRecord(ProcessingStatus.SUCCESS, FailureCounters.zero());
recordRepo.setLookupResult(new DocumentTerminalSuccess(existingRecord)); recordRepo.setLookupResult(new DocumentTerminalSuccess(existingRecord));
DocumentProcessingOutcome outcome = new PreCheckPassed( DocumentProcessingOutcome outcome = new PreCheckPassed(
@@ -635,7 +651,8 @@ class DocumentProcessingCoordinatorTest {
// Prüft, dass beim Überspringen eines final fehlgeschlagenen Dokuments geloggt wird // Prüft, dass beim Überspringen eines final fehlgeschlagenen Dokuments geloggt wird
CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger(); CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger();
DocumentProcessingCoordinator coordinatorWithCapturingLogger = DocumentProcessingCoordinator coordinatorWithCapturingLogger =
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, capturingLogger); new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger);
DocumentRecord existingRecord = buildRecord(ProcessingStatus.FAILED_FINAL, new FailureCounters(2, 0)); DocumentRecord existingRecord = buildRecord(ProcessingStatus.FAILED_FINAL, new FailureCounters(2, 0));
recordRepo.setLookupResult(new DocumentTerminalFinalFailure(existingRecord)); recordRepo.setLookupResult(new DocumentTerminalFinalFailure(existingRecord));
DocumentProcessingOutcome outcome = new PreCheckFailed( DocumentProcessingOutcome outcome = new PreCheckFailed(
@@ -652,7 +669,8 @@ class DocumentProcessingCoordinatorTest {
// Prüft, dass nach erfolgreichem Persistieren einer neuen Datei geloggt wird // Prüft, dass nach erfolgreichem Persistieren einer neuen Datei geloggt wird
CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger(); CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger();
DocumentProcessingCoordinator coordinatorWithCapturingLogger = DocumentProcessingCoordinator coordinatorWithCapturingLogger =
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, capturingLogger); new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger);
recordRepo.setLookupResult(new DocumentUnknown()); recordRepo.setLookupResult(new DocumentUnknown());
DocumentProcessingOutcome outcome = new PreCheckPassed( DocumentProcessingOutcome outcome = new PreCheckPassed(
candidate, new PdfExtractionSuccess("text", new PdfPageCount(1))); candidate, new PdfExtractionSuccess("text", new PdfPageCount(1)));
@@ -668,7 +686,8 @@ class DocumentProcessingCoordinatorTest {
// Prüft, dass bei Persistenzfehler ein Fehler-Log-Eintrag erzeugt wird // Prüft, dass bei Persistenzfehler ein Fehler-Log-Eintrag erzeugt wird
CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger(); CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger();
DocumentProcessingCoordinator coordinatorWithCapturingLogger = DocumentProcessingCoordinator coordinatorWithCapturingLogger =
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, capturingLogger); new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger);
recordRepo.setLookupResult(new DocumentUnknown()); recordRepo.setLookupResult(new DocumentUnknown());
unitOfWorkPort.failOnExecute = true; unitOfWorkPort.failOnExecute = true;
DocumentProcessingOutcome outcome = new PreCheckPassed( DocumentProcessingOutcome outcome = new PreCheckPassed(
@@ -685,7 +704,8 @@ class DocumentProcessingCoordinatorTest {
// Prüft, dass nach erfolgreichem Skip-Persistieren ein Debug-Log erzeugt wird (persistSkipAttempt L301) // Prüft, dass nach erfolgreichem Skip-Persistieren ein Debug-Log erzeugt wird (persistSkipAttempt L301)
CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger(); CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger();
DocumentProcessingCoordinator coordinatorWithCapturingLogger = DocumentProcessingCoordinator coordinatorWithCapturingLogger =
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, capturingLogger); new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger);
DocumentRecord existingRecord = buildRecord(ProcessingStatus.SUCCESS, FailureCounters.zero()); DocumentRecord existingRecord = buildRecord(ProcessingStatus.SUCCESS, FailureCounters.zero());
recordRepo.setLookupResult(new DocumentTerminalSuccess(existingRecord)); recordRepo.setLookupResult(new DocumentTerminalSuccess(existingRecord));
DocumentProcessingOutcome outcome = new PreCheckPassed( DocumentProcessingOutcome outcome = new PreCheckPassed(
@@ -702,7 +722,8 @@ class DocumentProcessingCoordinatorTest {
// Prüft, dass bei Persistenzfehler im Skip-Pfad ein Fehler geloggt wird (persistSkipAttempt L306) // Prüft, dass bei Persistenzfehler im Skip-Pfad ein Fehler geloggt wird (persistSkipAttempt L306)
CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger(); CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger();
DocumentProcessingCoordinator coordinatorWithCapturingLogger = DocumentProcessingCoordinator coordinatorWithCapturingLogger =
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, capturingLogger); new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger);
DocumentRecord existingRecord = buildRecord(ProcessingStatus.SUCCESS, FailureCounters.zero()); DocumentRecord existingRecord = buildRecord(ProcessingStatus.SUCCESS, FailureCounters.zero());
recordRepo.setLookupResult(new DocumentTerminalSuccess(existingRecord)); recordRepo.setLookupResult(new DocumentTerminalSuccess(existingRecord));
unitOfWorkPort.failOnExecute = true; unitOfWorkPort.failOnExecute = true;
@@ -715,10 +736,192 @@ class DocumentProcessingCoordinatorTest {
"Bei Persistenzfehler im Skip-Pfad muss ein Fehler geloggt werden"); "Bei Persistenzfehler im Skip-Pfad muss ein Fehler geloggt werden");
} }
// -------------------------------------------------------------------------
// PROPOSAL_READY finalization path
// -------------------------------------------------------------------------
@Test
void processDeferredOutcome_proposalReady_successfulCopy_persistsSuccessWithTargetFileName() {
DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
attemptRepo.savedAttempts.add(buildValidProposalAttempt());
boolean result = processor.processDeferredOutcome(candidate, fingerprint, context, attemptStart,
c -> { throw new AssertionError("Pipeline must not run for PROPOSAL_READY"); });
assertTrue(result, "Finalization should succeed");
ProcessingAttempt successAttempt = attemptRepo.savedAttempts.stream()
.filter(a -> a.status() == ProcessingStatus.SUCCESS)
.findFirst()
.orElse(null);
assertNotNull(successAttempt, "A SUCCESS attempt must be persisted");
assertNotNull(successAttempt.finalTargetFileName(), "SUCCESS attempt must carry the final target filename");
DocumentRecord updated = recordRepo.updatedRecords.get(0);
assertEquals(ProcessingStatus.SUCCESS, updated.overallStatus());
assertNotNull(updated.lastTargetFileName(), "Master record must carry the final target filename");
assertNotNull(updated.lastTargetPath(), "Master record must carry the target folder path");
assertNotNull(updated.lastSuccessInstant(), "lastSuccessInstant must be set on SUCCESS");
}
@Test
void processDeferredOutcome_proposalReady_missingProposalAttempt_persistsTransientError() {
DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
// No PROPOSAL_READY attempt pre-populated
// persistTransientError returns true when the error record was persisted successfully
processor.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
ProcessingAttempt errorAttempt = attemptRepo.savedAttempts.stream()
.filter(a -> a.status() == ProcessingStatus.FAILED_RETRYABLE)
.findFirst()
.orElse(null);
assertNotNull(errorAttempt, "A FAILED_RETRYABLE attempt must be persisted");
assertTrue(errorAttempt.retryable(), "Transient error must be retryable");
}
@Test
void processDeferredOutcome_proposalReady_inconsistentProposalNullDate_persistsTransientError() {
DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
ProcessingAttempt badProposal = new ProcessingAttempt(
fingerprint, context.runId(), 1, Instant.now(), Instant.now(),
ProcessingStatus.PROPOSAL_READY, null, null, false,
"model", "prompt", 1, 100, "{}", "reason",
null, DateSource.AI_PROVIDED, "Rechnung", null);
attemptRepo.savedAttempts.add(badProposal);
processor.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
ProcessingAttempt errorAttempt = attemptRepo.savedAttempts.stream()
.filter(a -> a.status() == ProcessingStatus.FAILED_RETRYABLE)
.findFirst()
.orElse(null);
assertNotNull(errorAttempt, "A FAILED_RETRYABLE attempt must be persisted for inconsistent proposal state");
}
@Test
void processDeferredOutcome_proposalReady_duplicateResolutionFailure_persistsTransientError() {
DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
attemptRepo.savedAttempts.add(buildValidProposalAttempt());
DocumentProcessingCoordinator coordinatorWithFailingFolder = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWorkPort,
new FailingTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger());
coordinatorWithFailingFolder.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
ProcessingAttempt errorAttempt = attemptRepo.savedAttempts.stream()
.filter(a -> a.status() == ProcessingStatus.FAILED_RETRYABLE)
.findFirst()
.orElse(null);
assertNotNull(errorAttempt, "A FAILED_RETRYABLE attempt must be persisted when duplicate resolution fails");
}
@Test
void processDeferredOutcome_proposalReady_copyFailure_persistsTransientError() {
DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
attemptRepo.savedAttempts.add(buildValidProposalAttempt());
DocumentProcessingCoordinator coordinatorWithFailingCopy = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new FailingTargetFileCopyPort(), new NoOpProcessingLogger());
coordinatorWithFailingCopy.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
ProcessingAttempt errorAttempt = attemptRepo.savedAttempts.stream()
.filter(a -> a.status() == ProcessingStatus.FAILED_RETRYABLE)
.findFirst()
.orElse(null);
assertNotNull(errorAttempt, "A FAILED_RETRYABLE attempt must be persisted when file copy fails");
}
@Test
void processDeferredOutcome_proposalReady_inconsistentProposalTitleExceeds20Chars_persistsTransientError() {
DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
// Title of 21 characters violates the 20-char base-title rule — inconsistent persistence state
ProcessingAttempt badProposal = new ProcessingAttempt(
fingerprint, context.runId(), 1, Instant.now(), Instant.now(),
ProcessingStatus.PROPOSAL_READY, null, null, false,
"model", "prompt", 1, 100, "{}", "reason",
LocalDate.of(2026, 1, 15), DateSource.AI_PROVIDED,
"A".repeat(21), null);
attemptRepo.savedAttempts.add(badProposal);
processor.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
ProcessingAttempt errorAttempt = attemptRepo.savedAttempts.stream()
.filter(a -> a.status() == ProcessingStatus.FAILED_RETRYABLE)
.findFirst()
.orElse(null);
assertNotNull(errorAttempt,
"A FAILED_RETRYABLE attempt must be persisted when the proposal title is inconsistent");
assertTrue(errorAttempt.retryable(), "Inconsistent proposal error must be retryable");
}
@Test
void processDeferredOutcome_proposalReady_inconsistentProposalTitleWithDisallowedChars_persistsTransientError() {
DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
// Hyphen is a disallowed character in the fachliche Titelregel
ProcessingAttempt badProposal = new ProcessingAttempt(
fingerprint, context.runId(), 1, Instant.now(), Instant.now(),
ProcessingStatus.PROPOSAL_READY, null, null, false,
"model", "prompt", 1, 100, "{}", "reason",
LocalDate.of(2026, 1, 15), DateSource.AI_PROVIDED,
"Rechnung-2026", null);
attemptRepo.savedAttempts.add(badProposal);
processor.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
ProcessingAttempt errorAttempt = attemptRepo.savedAttempts.stream()
.filter(a -> a.status() == ProcessingStatus.FAILED_RETRYABLE)
.findFirst()
.orElse(null);
assertNotNull(errorAttempt,
"A FAILED_RETRYABLE attempt must be persisted when the proposal title has disallowed characters");
}
@Test
void processDeferredOutcome_proposalReady_persistenceFailureAfterCopy_returnsFalse() {
DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
attemptRepo.savedAttempts.add(buildValidProposalAttempt());
unitOfWorkPort.failOnExecute = true;
boolean result = processor.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
assertFalse(result, "Should return false when persistence fails after successful copy");
}
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Helpers // Helpers
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
private ProcessingAttempt buildValidProposalAttempt() {
return new ProcessingAttempt(
fingerprint, context.runId(), 1, Instant.now(), Instant.now(),
ProcessingStatus.PROPOSAL_READY, null, null, false,
"gpt-4", "prompt-v1.txt", 1, 500, "{}", "reason",
LocalDate.of(2026, 1, 15), DateSource.AI_PROVIDED, "Rechnung", null);
}
private DocumentProcessingOutcome buildNamingProposalOutcome() {
AiAttemptContext ctx = new AiAttemptContext(
"gpt-4", "prompt-v1.txt", 1, 500, "{\"title\":\"Rechnung\",\"reasoning\":\"r\"}");
NamingProposal proposal = new NamingProposal(
LocalDate.of(2026, 1, 15), DateSource.AI_PROVIDED, "Rechnung", "AI reasoning");
return new NamingProposalReady(candidate, proposal, ctx);
}
private DocumentRecord buildRecord(ProcessingStatus status, FailureCounters counters) { private DocumentRecord buildRecord(ProcessingStatus status, FailureCounters counters) {
Instant now = Instant.now(); Instant now = Instant.now();
return new DocumentRecord( return new DocumentRecord(
@@ -730,7 +933,9 @@ class DocumentProcessingCoordinatorTest {
status == ProcessingStatus.SUCCESS ? null : now, status == ProcessingStatus.SUCCESS ? null : now,
status == ProcessingStatus.SUCCESS ? now : null, status == ProcessingStatus.SUCCESS ? now : null,
now, now,
now now,
null,
null
); );
} }
@@ -785,6 +990,14 @@ class DocumentProcessingCoordinatorTest {
public List<ProcessingAttempt> findAllByFingerprint(DocumentFingerprint fingerprint) { public List<ProcessingAttempt> findAllByFingerprint(DocumentFingerprint fingerprint) {
return List.copyOf(savedAttempts); return List.copyOf(savedAttempts);
} }
@Override
public ProcessingAttempt findLatestProposalReadyAttempt(DocumentFingerprint fingerprint) {
return savedAttempts.stream()
.filter(a -> a.status() == de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus.PROPOSAL_READY)
.reduce((first, second) -> second)
.orElse(null);
}
} }
private static class CapturingUnitOfWorkPort implements UnitOfWorkPort { private static class CapturingUnitOfWorkPort implements UnitOfWorkPort {
@@ -850,6 +1063,58 @@ class DocumentProcessingCoordinatorTest {
} }
} }
private static class FailingTargetFolderPort implements TargetFolderPort {
@Override
public String getTargetFolderLocator() {
return "/tmp/target";
}
@Override
public TargetFilenameResolutionResult resolveUniqueFilename(String baseName) {
return new TargetFolderTechnicalFailure("Simulated folder resolution failure");
}
@Override
public void tryDeleteTargetFile(String resolvedFilename) {
// No-op
}
}
private static class FailingTargetFileCopyPort implements TargetFileCopyPort {
@Override
public TargetFileCopyResult copyToTarget(
de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator sourceLocator,
String resolvedFilename) {
return new TargetFileCopyTechnicalFailure("Simulated copy failure", false);
}
}
private static class NoOpTargetFolderPort implements TargetFolderPort {
@Override
public String getTargetFolderLocator() {
return "/tmp/target";
}
@Override
public TargetFilenameResolutionResult resolveUniqueFilename(String baseName) {
return new ResolvedTargetFilename(baseName);
}
@Override
public void tryDeleteTargetFile(String resolvedFilename) {
// No-op
}
}
private static class NoOpTargetFileCopyPort implements TargetFileCopyPort {
@Override
public TargetFileCopyResult copyToTarget(
de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator sourceLocator,
String resolvedFilename) {
return new TargetFileCopySuccess();
}
}
/** Zählt Logger-Aufrufe je Level, um VoidMethodCallMutator-Mutationen zu erkennen. */ /** Zählt Logger-Aufrufe je Level, um VoidMethodCallMutator-Mutationen zu erkennen. */
private static class CapturingProcessingLogger implements ProcessingLogger { private static class CapturingProcessingLogger implements ProcessingLogger {
int infoCallCount = 0; int infoCallCount = 0;

View File

@@ -0,0 +1,94 @@
package de.gecheckt.pdf.umbenenner.application.service;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Unit tests for {@link DocumentTextLimiter}.
*/
class DocumentTextLimiterTest {
@Test
void limit_textShorterThanMax_returnsTextUnchanged() {
String text = "short text";
String result = DocumentTextLimiter.limit(text, 100);
assertThat(result).isEqualTo(text);
}
@Test
void limit_textExactlyMax_returnsTextUnchanged() {
String text = "exactly ten"; // 11 chars
String result = DocumentTextLimiter.limit(text, 11);
assertThat(result).isEqualTo(text);
assertThat(result).hasSize(11);
}
@Test
void limit_textLongerThanMax_returnsTruncatedText() {
String text = "Hello, World!";
String result = DocumentTextLimiter.limit(text, 5);
assertThat(result).isEqualTo("Hello");
assertThat(result).hasSize(5);
}
@Test
void limit_maxCharactersOne_returnsSingleChar() {
String text = "ABC";
String result = DocumentTextLimiter.limit(text, 1);
assertThat(result).isEqualTo("A");
}
@Test
void limit_emptyText_returnsEmptyString() {
String result = DocumentTextLimiter.limit("", 100);
assertThat(result).isEmpty();
}
@Test
void limit_emptyTextWithMinMax_returnsEmptyString() {
String result = DocumentTextLimiter.limit("", 1);
assertThat(result).isEmpty();
}
@Test
void limit_textWithUnicodeCharacters_respectsCharCount() {
// German umlauts are single chars in Java
String text = "Rechnungsübersicht"; // 18 chars
String result = DocumentTextLimiter.limit(text, 10);
assertThat(result).hasSize(10);
assertThat(result).startsWith("Rechnungs");
}
@Test
void limit_nullText_throwsNullPointerException() {
assertThatThrownBy(() -> DocumentTextLimiter.limit(null, 100))
.isInstanceOf(NullPointerException.class)
.hasMessage("text must not be null");
}
@Test
void limit_maxCharactersZero_throwsIllegalArgumentException() {
assertThatThrownBy(() -> DocumentTextLimiter.limit("text", 0))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("maxCharacters must be >= 1");
}
@Test
void limit_negativeMaxCharacters_throwsIllegalArgumentException() {
assertThatThrownBy(() -> DocumentTextLimiter.limit("text", -5))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("maxCharacters must be >= 1");
}
@Test
void limit_doesNotModifyOriginalText() {
String original = "This is the original document text that is long";
String limited = DocumentTextLimiter.limit(original, 10);
// The original String object is unchanged (Java Strings are immutable)
assertThat(limited).isNotSameAs(original);
assertThat(limited).hasSize(10);
}
}

View File

@@ -0,0 +1,264 @@
package de.gecheckt.pdf.umbenenner.application.service;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
import de.gecheckt.pdf.umbenenner.application.service.TargetFilenameBuildingService.BaseFilenameReady;
import de.gecheckt.pdf.umbenenner.application.service.TargetFilenameBuildingService.BaseFilenameResult;
import de.gecheckt.pdf.umbenenner.application.service.TargetFilenameBuildingService.InconsistentProposalState;
import de.gecheckt.pdf.umbenenner.domain.model.DateSource;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.time.LocalDate;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
/**
* Unit tests for {@link TargetFilenameBuildingService}.
* <p>
* Covers the verbindliches Zielformat {@code YYYY-MM-DD - Titel.pdf}, the 20-character
* base-title rule, the fachliche Titelregel (only letters, digits, and spaces), and the
* detection of inconsistent persistence states.
*/
class TargetFilenameBuildingServiceTest {
private static final DocumentFingerprint FINGERPRINT =
new DocumentFingerprint("a".repeat(64));
private static final RunId RUN_ID = new RunId("run-test");
// -------------------------------------------------------------------------
// Null guard
// -------------------------------------------------------------------------
@Test
void buildBaseFilename_rejectsNullAttempt() {
assertThatNullPointerException()
.isThrownBy(() -> TargetFilenameBuildingService.buildBaseFilename(null));
}
// -------------------------------------------------------------------------
// Happy path correct format
// -------------------------------------------------------------------------
@Test
void buildBaseFilename_validProposal_returnsCorrectFormat() {
ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 1, 15), "Rechnung");
BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt);
assertThat(result).isInstanceOf(BaseFilenameReady.class);
assertThat(((BaseFilenameReady) result).baseFilename())
.isEqualTo("2026-01-15 - Rechnung.pdf");
}
@Test
void buildBaseFilename_dateWithLeadingZeros_formatsCorrectly() {
ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 3, 5), "Kontoauszug");
BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt);
assertThat(result).isInstanceOf(BaseFilenameReady.class);
assertThat(((BaseFilenameReady) result).baseFilename())
.isEqualTo("2026-03-05 - Kontoauszug.pdf");
}
@Test
void buildBaseFilename_titleWithDigits_isAccepted() {
ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 6, 1), "Rechnung 2026");
BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt);
assertThat(result).isInstanceOf(BaseFilenameReady.class);
assertThat(((BaseFilenameReady) result).baseFilename())
.isEqualTo("2026-06-01 - Rechnung 2026.pdf");
}
@Test
void buildBaseFilename_titleWithGermanUmlauts_isAccepted() {
ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 4, 7), "Strom Abr");
BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt);
assertThat(result).isInstanceOf(BaseFilenameReady.class);
}
@Test
void buildBaseFilename_titleWithUmlautsAndSzlig_isAccepted() {
// ä, ö, ü, ß are Unicode letters and must be accepted
ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 4, 7), "Büroausgabe");
BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt);
assertThat(result).isInstanceOf(BaseFilenameReady.class);
assertThat(((BaseFilenameReady) result).baseFilename())
.isEqualTo("2026-04-07 - Büroausgabe.pdf");
}
@Test
void buildBaseFilename_titleExactly20Chars_isAccepted() {
String title = "A".repeat(20); // exactly 20 characters
ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 1, 1), title);
BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt);
assertThat(result).isInstanceOf(BaseFilenameReady.class);
}
// -------------------------------------------------------------------------
// 20-character rule applies only to base title; format structure is separate
// -------------------------------------------------------------------------
@Test
void buildBaseFilename_format_separatorAndExtensionAreNotCountedAgainstTitle() {
// A 20-char title produces "YYYY-MM-DD - <20chars>.pdf" — total > 20 chars, which is fine
String title = "Stromabrechnung 2026"; // 20 chars
ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 3, 31), title);
BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt);
assertThat(result).isInstanceOf(BaseFilenameReady.class);
String filename = ((BaseFilenameReady) result).baseFilename();
assertThat(filename).isEqualTo("2026-03-31 - Stromabrechnung 2026.pdf");
// The service does not append duplicate suffixes; those are added by the target folder adapter
assertThat(filename).doesNotContain("(");
}
// -------------------------------------------------------------------------
// InconsistentProposalState null/invalid date
// -------------------------------------------------------------------------
@Test
void buildBaseFilename_nullDate_returnsInconsistentProposalState() {
ProcessingAttempt attempt = proposalAttempt(null, "Rechnung");
BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt);
assertThat(result).isInstanceOf(InconsistentProposalState.class);
assertThat(((InconsistentProposalState) result).reason())
.contains("no resolved date");
}
// -------------------------------------------------------------------------
// InconsistentProposalState null/blank title
// -------------------------------------------------------------------------
@Test
void buildBaseFilename_nullTitle_returnsInconsistentProposalState() {
ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 1, 1), null);
BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt);
assertThat(result).isInstanceOf(InconsistentProposalState.class);
assertThat(((InconsistentProposalState) result).reason())
.contains("no validated title");
}
@Test
void buildBaseFilename_blankTitle_returnsInconsistentProposalState() {
ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 1, 1), " ");
BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt);
assertThat(result).isInstanceOf(InconsistentProposalState.class);
assertThat(((InconsistentProposalState) result).reason())
.contains("no validated title");
}
// -------------------------------------------------------------------------
// InconsistentProposalState title exceeds 20 characters
// -------------------------------------------------------------------------
@Test
void buildBaseFilename_titleExceeds20Chars_returnsInconsistentProposalState() {
String title = "A".repeat(21); // 21 characters
ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 1, 1), title);
BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt);
assertThat(result).isInstanceOf(InconsistentProposalState.class);
assertThat(((InconsistentProposalState) result).reason())
.contains("exceeding 20 characters");
}
// -------------------------------------------------------------------------
// InconsistentProposalState disallowed characters in title
// -------------------------------------------------------------------------
@Test
void buildBaseFilename_titleWithHyphen_returnsInconsistentProposalState() {
// Hyphens are not letters, digits, or spaces — disallowed by fachliche Titelregel
ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 1, 1), "Rechnung-2026");
BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt);
assertThat(result).isInstanceOf(InconsistentProposalState.class);
assertThat(((InconsistentProposalState) result).reason())
.contains("disallowed characters");
}
@Test
void buildBaseFilename_titleWithSlash_returnsInconsistentProposalState() {
ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 1, 1), "Rg/Strom");
BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt);
assertThat(result).isInstanceOf(InconsistentProposalState.class);
}
@Test
void buildBaseFilename_titleWithDot_returnsInconsistentProposalState() {
ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 1, 1), "Rechnung.pdf");
BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt);
assertThat(result).isInstanceOf(InconsistentProposalState.class);
}
// -------------------------------------------------------------------------
// InconsistentProposalState reason field is non-null
// -------------------------------------------------------------------------
@Test
void incosistentProposalState_reason_isNeverNull() {
ProcessingAttempt attempt = proposalAttempt(null, "Rechnung");
InconsistentProposalState state =
(InconsistentProposalState) TargetFilenameBuildingService.buildBaseFilename(attempt);
assertThat(state.reason()).isNotNull();
}
// -------------------------------------------------------------------------
// BaseFilenameReady result record is non-null and non-blank
// -------------------------------------------------------------------------
@Test
void baseFilenameReady_baseFilename_isNeverNullOrBlank() {
ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 7, 4), "Bescheid");
BaseFilenameReady ready =
(BaseFilenameReady) TargetFilenameBuildingService.buildBaseFilename(attempt);
assertThat(ready.baseFilename()).isNotNull().isNotBlank();
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private ProcessingAttempt proposalAttempt(LocalDate date, String title) {
return new ProcessingAttempt(
FINGERPRINT, RUN_ID, 1,
Instant.now(), Instant.now(),
ProcessingStatus.PROPOSAL_READY,
null, null, false,
"gpt-4", "prompt-v1.txt", 1, 100,
"{}", "reasoning text",
date, DateSource.AI_PROVIDED, title,
null);
}
}

View File

@@ -2,6 +2,9 @@ package de.gecheckt.pdf.umbenenner.application.usecase;
import de.gecheckt.pdf.umbenenner.application.config.RuntimeConfiguration; import de.gecheckt.pdf.umbenenner.application.config.RuntimeConfiguration;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome; import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationPort;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationTechnicalFailure;
import de.gecheckt.pdf.umbenenner.application.port.out.ClockPort;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord; import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordLookupResult; import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordLookupResult;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordRepository; import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordRepository;
@@ -14,12 +17,23 @@ import de.gecheckt.pdf.umbenenner.application.port.out.PdfTextExtractionPort;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt; import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttemptRepository; import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttemptRepository;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingLogger; import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingLogger;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingSuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.ResolvedTargetFilename;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopyPort;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopyResult;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopySuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFilenameResolutionResult;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFolderPort;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptPort;
import de.gecheckt.pdf.umbenenner.application.port.out.RunLockPort; import de.gecheckt.pdf.umbenenner.application.port.out.RunLockPort;
import de.gecheckt.pdf.umbenenner.application.port.out.RunLockUnavailableException; import de.gecheckt.pdf.umbenenner.application.port.out.RunLockUnavailableException;
import de.gecheckt.pdf.umbenenner.application.port.out.SourceDocumentAccessException; import de.gecheckt.pdf.umbenenner.application.port.out.SourceDocumentAccessException;
import de.gecheckt.pdf.umbenenner.application.port.out.SourceDocumentCandidatesPort; import de.gecheckt.pdf.umbenenner.application.port.out.SourceDocumentCandidatesPort;
import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort; import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort;
import de.gecheckt.pdf.umbenenner.application.service.AiNamingService;
import de.gecheckt.pdf.umbenenner.application.service.AiResponseValidator;
import de.gecheckt.pdf.umbenenner.application.service.DocumentProcessingCoordinator; import de.gecheckt.pdf.umbenenner.application.service.DocumentProcessingCoordinator;
import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier;
import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext; import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint; import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.PdfExtractionContentError; import de.gecheckt.pdf.umbenenner.domain.model.PdfExtractionContentError;
@@ -445,7 +459,8 @@ class BatchRunProcessingUseCaseTest {
// Use a coordinator that always fails persistence // Use a coordinator that always fails persistence
DocumentProcessingCoordinator failingProcessor = new DocumentProcessingCoordinator( DocumentProcessingCoordinator failingProcessor = new DocumentProcessingCoordinator(
new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(), new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(),
new NoOpUnitOfWorkPort(), new NoOpProcessingLogger()) { new NoOpUnitOfWorkPort(), new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(),
new NoOpProcessingLogger()) {
@Override @Override
public boolean processDeferredOutcome( public boolean processDeferredOutcome(
de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate candidate, de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate candidate,
@@ -488,7 +503,8 @@ class BatchRunProcessingUseCaseTest {
// Coordinator that succeeds for first document, fails persistence for second // Coordinator that succeeds for first document, fails persistence for second
DocumentProcessingCoordinator selectiveFailingProcessor = new DocumentProcessingCoordinator( DocumentProcessingCoordinator selectiveFailingProcessor = new DocumentProcessingCoordinator(
new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(), new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(),
new NoOpUnitOfWorkPort(), new NoOpProcessingLogger()) { new NoOpUnitOfWorkPort(), new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(),
new NoOpProcessingLogger()) {
private int callCount = 0; private int callCount = 0;
@Override @Override
@@ -535,7 +551,7 @@ class BatchRunProcessingUseCaseTest {
DefaultBatchRunProcessingUseCase useCase = new DefaultBatchRunProcessingUseCase( DefaultBatchRunProcessingUseCase useCase = new DefaultBatchRunProcessingUseCase(
config, new MockRunLockPort(), candidatesPort, new NoOpExtractionPort(), config, new MockRunLockPort(), candidatesPort, new NoOpExtractionPort(),
alwaysFailingFingerprintPort, new NoOpDocumentProcessingCoordinator(), alwaysFailingFingerprintPort, new NoOpDocumentProcessingCoordinator(),
capturingLogger); buildStubAiNamingService(), capturingLogger);
useCase.execute(new BatchRunContext(new RunId("fp-warn"), Instant.now())); useCase.execute(new BatchRunContext(new RunId("fp-warn"), Instant.now()));
@@ -556,7 +572,7 @@ class BatchRunProcessingUseCaseTest {
DefaultBatchRunProcessingUseCase useCase = new DefaultBatchRunProcessingUseCase( DefaultBatchRunProcessingUseCase useCase = new DefaultBatchRunProcessingUseCase(
config, new MockRunLockPort(), failingPort, new NoOpExtractionPort(), config, new MockRunLockPort(), failingPort, new NoOpExtractionPort(),
new AlwaysSuccessFingerprintPort(), new NoOpDocumentProcessingCoordinator(), new AlwaysSuccessFingerprintPort(), new NoOpDocumentProcessingCoordinator(),
capturingLogger); buildStubAiNamingService(), capturingLogger);
useCase.execute(new BatchRunContext(new RunId("source-err"), Instant.now())); useCase.execute(new BatchRunContext(new RunId("source-err"), Instant.now()));
@@ -578,7 +594,8 @@ class BatchRunProcessingUseCaseTest {
// Coordinator der immer Persistenzfehler zurückgibt // Coordinator der immer Persistenzfehler zurückgibt
DocumentProcessingCoordinator failingCoordinator = new DocumentProcessingCoordinator( DocumentProcessingCoordinator failingCoordinator = new DocumentProcessingCoordinator(
new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(), new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(),
new NoOpUnitOfWorkPort(), new NoOpProcessingLogger()) { new NoOpUnitOfWorkPort(), new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(),
new NoOpProcessingLogger()) {
@Override @Override
public boolean processDeferredOutcome( public boolean processDeferredOutcome(
de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate c, de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate c,
@@ -592,7 +609,7 @@ class BatchRunProcessingUseCaseTest {
DefaultBatchRunProcessingUseCase useCase = new DefaultBatchRunProcessingUseCase( DefaultBatchRunProcessingUseCase useCase = new DefaultBatchRunProcessingUseCase(
config, new MockRunLockPort(), candidatesPort, extractionPort, config, new MockRunLockPort(), candidatesPort, extractionPort,
new AlwaysSuccessFingerprintPort(), failingCoordinator, capturingLogger); new AlwaysSuccessFingerprintPort(), failingCoordinator, buildStubAiNamingService(), capturingLogger);
useCase.execute(new BatchRunContext(new RunId("persist-warn"), Instant.now())); useCase.execute(new BatchRunContext(new RunId("persist-warn"), Instant.now()));
@@ -610,7 +627,7 @@ class BatchRunProcessingUseCaseTest {
DefaultBatchRunProcessingUseCase useCase = new DefaultBatchRunProcessingUseCase( DefaultBatchRunProcessingUseCase useCase = new DefaultBatchRunProcessingUseCase(
config, new MockRunLockPort(), new EmptyCandidatesPort(), new NoOpExtractionPort(), config, new MockRunLockPort(), new EmptyCandidatesPort(), new NoOpExtractionPort(),
new AlwaysSuccessFingerprintPort(), new NoOpDocumentProcessingCoordinator(), new AlwaysSuccessFingerprintPort(), new NoOpDocumentProcessingCoordinator(),
capturingLogger); buildStubAiNamingService(), capturingLogger);
useCase.execute(new BatchRunContext(new RunId("start-log"), Instant.now())); useCase.execute(new BatchRunContext(new RunId("start-log"), Instant.now()));
@@ -630,7 +647,7 @@ class BatchRunProcessingUseCaseTest {
DefaultBatchRunProcessingUseCase useCase = new DefaultBatchRunProcessingUseCase( DefaultBatchRunProcessingUseCase useCase = new DefaultBatchRunProcessingUseCase(
config, lockPort, new EmptyCandidatesPort(), new NoOpExtractionPort(), config, lockPort, new EmptyCandidatesPort(), new NoOpExtractionPort(),
new AlwaysSuccessFingerprintPort(), new NoOpDocumentProcessingCoordinator(), new AlwaysSuccessFingerprintPort(), new NoOpDocumentProcessingCoordinator(),
capturingLogger); buildStubAiNamingService(), capturingLogger);
useCase.execute(new BatchRunContext(new RunId("lock-warn"), Instant.now())); useCase.execute(new BatchRunContext(new RunId("lock-warn"), Instant.now()));
@@ -659,11 +676,11 @@ class BatchRunProcessingUseCaseTest {
DefaultBatchRunProcessingUseCase useCase = new DefaultBatchRunProcessingUseCase( DefaultBatchRunProcessingUseCase useCase = new DefaultBatchRunProcessingUseCase(
config, new MockRunLockPort(), candidatesPort, extractionPort, config, new MockRunLockPort(), candidatesPort, extractionPort,
new AlwaysSuccessFingerprintPort(), processor, capturingLogger); new AlwaysSuccessFingerprintPort(), processor, buildStubAiNamingService(), capturingLogger);
useCase.execute(new BatchRunContext(new RunId("log-precheck"), Instant.now())); useCase.execute(new BatchRunContext(new RunId("log-precheck"), Instant.now()));
// Ohne logExtractionResult wären es 4 debug()-Aufrufe; mit logExtractionResult 5 // Ohne logExtractionResult wären es mindestens 4 debug()-Aufrufe; mit logExtractionResult 5
assertTrue(capturingLogger.debugCallCount >= 5, assertTrue(capturingLogger.debugCallCount >= 5,
"logExtractionResult muss bei PdfExtractionSuccess debug() aufrufen (erwartet >= 5, war: " "logExtractionResult muss bei PdfExtractionSuccess debug() aufrufen (erwartet >= 5, war: "
+ capturingLogger.debugCallCount + ")"); + capturingLogger.debugCallCount + ")");
@@ -689,7 +706,7 @@ class BatchRunProcessingUseCaseTest {
DefaultBatchRunProcessingUseCase useCase = new DefaultBatchRunProcessingUseCase( DefaultBatchRunProcessingUseCase useCase = new DefaultBatchRunProcessingUseCase(
config, new MockRunLockPort(), candidatesPort, extractionPort, config, new MockRunLockPort(), candidatesPort, extractionPort,
new AlwaysSuccessFingerprintPort(), processor, capturingLogger); new AlwaysSuccessFingerprintPort(), processor, buildStubAiNamingService(), capturingLogger);
useCase.execute(new BatchRunContext(new RunId("log-content-error"), Instant.now())); useCase.execute(new BatchRunContext(new RunId("log-content-error"), Instant.now()));
@@ -718,7 +735,7 @@ class BatchRunProcessingUseCaseTest {
DefaultBatchRunProcessingUseCase useCase = new DefaultBatchRunProcessingUseCase( DefaultBatchRunProcessingUseCase useCase = new DefaultBatchRunProcessingUseCase(
config, new MockRunLockPort(), candidatesPort, extractionPort, config, new MockRunLockPort(), candidatesPort, extractionPort,
new AlwaysSuccessFingerprintPort(), processor, capturingLogger); new AlwaysSuccessFingerprintPort(), processor, buildStubAiNamingService(), capturingLogger);
useCase.execute(new BatchRunContext(new RunId("log-tech-error"), Instant.now())); useCase.execute(new BatchRunContext(new RunId("log-tech-error"), Instant.now()));
@@ -735,6 +752,20 @@ class BatchRunProcessingUseCaseTest {
// Helpers // Helpers
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
/**
* Builds a minimal stub {@link AiNamingService} that always returns an AI technical failure.
* Suitable for tests that do not care about the AI pipeline outcome.
*/
private static AiNamingService buildStubAiNamingService() {
AiInvocationPort stubAiPort = request ->
new AiInvocationTechnicalFailure(request, "STUBBED", "Stubbed AI for test");
PromptPort stubPromptPort = () ->
new PromptLoadingSuccess(new PromptIdentifier("stub-prompt"), "stub prompt content");
ClockPort stubClock = () -> java.time.Instant.EPOCH;
AiResponseValidator validator = new AiResponseValidator(stubClock);
return new AiNamingService(stubAiPort, stubPromptPort, validator, "stub-model", 1000);
}
private static DefaultBatchRunProcessingUseCase buildUseCase( private static DefaultBatchRunProcessingUseCase buildUseCase(
RuntimeConfiguration runtimeConfig, RuntimeConfiguration runtimeConfig,
RunLockPort lockPort, RunLockPort lockPort,
@@ -744,7 +775,7 @@ class BatchRunProcessingUseCaseTest {
DocumentProcessingCoordinator processor) { DocumentProcessingCoordinator processor) {
return new DefaultBatchRunProcessingUseCase( return new DefaultBatchRunProcessingUseCase(
runtimeConfig, lockPort, candidatesPort, extractionPort, fingerprintPort, processor, runtimeConfig, lockPort, candidatesPort, extractionPort, fingerprintPort, processor,
new NoOpProcessingLogger()); buildStubAiNamingService(), new NoOpProcessingLogger());
} }
private static RuntimeConfiguration buildConfig(Path tempDir) throws Exception { private static RuntimeConfiguration buildConfig(Path tempDir) throws Exception {
@@ -906,7 +937,7 @@ class BatchRunProcessingUseCaseTest {
private static class NoOpDocumentProcessingCoordinator extends DocumentProcessingCoordinator { private static class NoOpDocumentProcessingCoordinator extends DocumentProcessingCoordinator {
NoOpDocumentProcessingCoordinator() { NoOpDocumentProcessingCoordinator() {
super(new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(), new NoOpUnitOfWorkPort(), super(new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(), new NoOpUnitOfWorkPort(),
new NoOpProcessingLogger()); new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger());
} }
} }
@@ -918,7 +949,7 @@ class BatchRunProcessingUseCaseTest {
TrackingDocumentProcessingCoordinator() { TrackingDocumentProcessingCoordinator() {
super(new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(), new NoOpUnitOfWorkPort(), super(new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(), new NoOpUnitOfWorkPort(),
new NoOpProcessingLogger()); new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger());
} }
@Override @Override
@@ -948,6 +979,32 @@ class BatchRunProcessingUseCaseTest {
int processCallCount() { return processCallCount; } int processCallCount() { return processCallCount; }
} }
private static class NoOpTargetFolderPort implements TargetFolderPort {
@Override
public String getTargetFolderLocator() {
return "/tmp/target";
}
@Override
public TargetFilenameResolutionResult resolveUniqueFilename(String baseName) {
return new ResolvedTargetFilename(baseName);
}
@Override
public void tryDeleteTargetFile(String resolvedFilename) {
// No-op
}
}
private static class NoOpTargetFileCopyPort implements TargetFileCopyPort {
@Override
public TargetFileCopyResult copyToTarget(
de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator sourceLocator,
String resolvedFilename) {
return new TargetFileCopySuccess();
}
}
/** No-op DocumentRecordRepository for use in test instances. */ /** No-op DocumentRecordRepository for use in test instances. */
private static class NoOpDocumentRecordRepository implements DocumentRecordRepository { private static class NoOpDocumentRecordRepository implements DocumentRecordRepository {
@Override @Override
@@ -983,6 +1040,11 @@ class BatchRunProcessingUseCaseTest {
public List<ProcessingAttempt> findAllByFingerprint(DocumentFingerprint fingerprint) { public List<ProcessingAttempt> findAllByFingerprint(DocumentFingerprint fingerprint) {
return List.of(); return List.of();
} }
@Override
public ProcessingAttempt findLatestProposalReadyAttempt(DocumentFingerprint fingerprint) {
return null;
}
} }
/** No-op UnitOfWorkPort for use in test instances. */ /** No-op UnitOfWorkPort for use in test instances. */

View File

@@ -9,31 +9,43 @@ import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.adapter.in.cli.SchedulerBatchCommand; import de.gecheckt.pdf.umbenenner.adapter.in.cli.SchedulerBatchCommand;
import de.gecheckt.pdf.umbenenner.adapter.out.ai.OpenAiHttpAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.InvalidStartConfigurationException; import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.InvalidStartConfigurationException;
import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.StartConfigurationValidator; import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.StartConfigurationValidator;
import de.gecheckt.pdf.umbenenner.adapter.out.clock.SystemClockAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.configuration.ConfigurationLoadingException; import de.gecheckt.pdf.umbenenner.adapter.out.configuration.ConfigurationLoadingException;
import de.gecheckt.pdf.umbenenner.adapter.out.configuration.PropertiesConfigurationPortAdapter; import de.gecheckt.pdf.umbenenner.adapter.out.configuration.PropertiesConfigurationPortAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.fingerprint.Sha256FingerprintAdapter; import de.gecheckt.pdf.umbenenner.adapter.out.fingerprint.Sha256FingerprintAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.lock.FilesystemRunLockPortAdapter; import de.gecheckt.pdf.umbenenner.adapter.out.lock.FilesystemRunLockPortAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.pdfextraction.PdfTextExtractionPortAdapter; import de.gecheckt.pdf.umbenenner.adapter.out.pdfextraction.PdfTextExtractionPortAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.prompt.FilesystemPromptPortAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.sourcedocument.SourceDocumentCandidatesPortAdapter; import de.gecheckt.pdf.umbenenner.adapter.out.sourcedocument.SourceDocumentCandidatesPortAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteDocumentRecordRepositoryAdapter; import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteDocumentRecordRepositoryAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteProcessingAttemptRepositoryAdapter; import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteProcessingAttemptRepositoryAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteSchemaInitializationAdapter; import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteSchemaInitializationAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteUnitOfWorkAdapter; import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteUnitOfWorkAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.targetcopy.FilesystemTargetFileCopyAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.targetfolder.FilesystemTargetFolderAdapter;
import de.gecheckt.pdf.umbenenner.application.config.RuntimeConfiguration; import de.gecheckt.pdf.umbenenner.application.config.RuntimeConfiguration;
import de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration; import de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome; import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProcessingUseCase; import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProcessingUseCase;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationPort;
import de.gecheckt.pdf.umbenenner.application.port.out.ClockPort;
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationPort; import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationPort;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopyPort;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFolderPort;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException; import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordRepository; import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordRepository;
import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintPort; import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintPort;
import de.gecheckt.pdf.umbenenner.application.port.out.PersistenceSchemaInitializationPort; import de.gecheckt.pdf.umbenenner.application.port.out.PersistenceSchemaInitializationPort;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttemptRepository; import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttemptRepository;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingLogger; import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingLogger;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptPort;
import de.gecheckt.pdf.umbenenner.application.port.out.RunLockPort; import de.gecheckt.pdf.umbenenner.application.port.out.RunLockPort;
import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort; import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort;
import de.gecheckt.pdf.umbenenner.application.service.AiNamingService;
import de.gecheckt.pdf.umbenenner.application.service.AiResponseValidator;
import de.gecheckt.pdf.umbenenner.application.service.DocumentProcessingCoordinator; import de.gecheckt.pdf.umbenenner.application.service.DocumentProcessingCoordinator;
import de.gecheckt.pdf.umbenenner.application.usecase.DefaultBatchRunProcessingUseCase; import de.gecheckt.pdf.umbenenner.application.usecase.DefaultBatchRunProcessingUseCase;
import de.gecheckt.pdf.umbenenner.bootstrap.adapter.Log4jProcessingLogger; import de.gecheckt.pdf.umbenenner.bootstrap.adapter.Log4jProcessingLogger;
@@ -71,11 +83,15 @@ import de.gecheckt.pdf.umbenenner.domain.model.RunId;
* <ul> * <ul>
* <li>{@link PropertiesConfigurationPortAdapter} — loads configuration from properties and environment.</li> * <li>{@link PropertiesConfigurationPortAdapter} — loads configuration from properties and environment.</li>
* <li>{@link FilesystemRunLockPortAdapter} — ensures exclusive execution via a lock file.</li> * <li>{@link FilesystemRunLockPortAdapter} — ensures exclusive execution via a lock file.</li>
* <li>{@link SqliteSchemaInitializationAdapter} — initializes SQLite schema at startup.</li> * <li>{@link SqliteSchemaInitializationAdapter} — initializes SQLite schema (including target-copy
* schema evolution) at startup.</li>
* <li>{@link Sha256FingerprintAdapter} — provides content-based document identification.</li> * <li>{@link Sha256FingerprintAdapter} — provides content-based document identification.</li>
* <li>{@link SqliteDocumentRecordRepositoryAdapter} — manages document master records.</li> * <li>{@link SqliteDocumentRecordRepositoryAdapter} — manages document master records.</li>
* <li>{@link SqliteProcessingAttemptRepositoryAdapter} — maintains attempt history.</li> * <li>{@link SqliteProcessingAttemptRepositoryAdapter} — maintains attempt history.</li>
* <li>{@link SqliteUnitOfWorkAdapter} — coordinates atomic persistence operations.</li> * <li>{@link SqliteUnitOfWorkAdapter} — coordinates atomic persistence operations.</li>
* <li>{@link FilesystemTargetFolderAdapter} — resolves unique filenames in the configured target folder.</li>
* <li>{@link FilesystemTargetFileCopyAdapter} — copies source documents to the target folder via
* a temporary file and final move/rename.</li>
* </ul> * </ul>
* <p> * <p>
* Schema initialization is performed exactly once in {@link #run()} before the batch processing loop * Schema initialization is performed exactly once in {@link #run()} before the batch processing loop
@@ -162,12 +178,22 @@ public class BootstrapRunner {
* <li>{@link SourceDocumentCandidatesPortAdapter} for PDF candidate discovery.</li> * <li>{@link SourceDocumentCandidatesPortAdapter} for PDF candidate discovery.</li>
* <li>{@link PdfTextExtractionPortAdapter} for PDFBox-based text and page count extraction.</li> * <li>{@link PdfTextExtractionPortAdapter} for PDFBox-based text and page count extraction.</li>
* <li>{@link Sha256FingerprintAdapter} for SHA-256 content fingerprinting.</li> * <li>{@link Sha256FingerprintAdapter} for SHA-256 content fingerprinting.</li>
* <li>{@link SqliteSchemaInitializationAdapter} for SQLite schema DDL at startup.</li> * <li>{@link SqliteSchemaInitializationAdapter} for SQLite schema DDL and target-copy schema
* evolution at startup.</li>
* <li>{@link SqliteDocumentRecordRepositoryAdapter} for document master record CRUD.</li> * <li>{@link SqliteDocumentRecordRepositoryAdapter} for document master record CRUD.</li>
* <li>{@link SqliteProcessingAttemptRepositoryAdapter} for attempt history CRUD.</li> * <li>{@link SqliteProcessingAttemptRepositoryAdapter} for attempt history CRUD.</li>
* <li>{@link SqliteUnitOfWorkAdapter} for atomic persistence operations.</li> * <li>{@link SqliteUnitOfWorkAdapter} for atomic persistence operations.</li>
* <li>{@link FilesystemTargetFolderAdapter} for duplicate-safe filename resolution in the
* configured {@code target.folder}.</li>
* <li>{@link FilesystemTargetFileCopyAdapter} for copying source documents to the target folder
* via a temporary file and final atomic move/rename.</li>
* </ul> * </ul>
* <p> * <p>
* Target folder availability and write access are validated in
* {@link #loadAndValidateConfiguration()} via {@link StartConfigurationValidator} before
* schema initialisation and batch processing begin. If the target folder does not yet exist,
* the validator creates it; failure to do so is a hard startup error.
* <p>
* Schema initialisation is performed explicitly in {@link #run()} before the batch loop * Schema initialisation is performed explicitly in {@link #run()} before the batch loop
* begins. Failure during initialisation aborts the run with exit code 1. * begins. Failure during initialisation aborts the run with exit code 1.
*/ */
@@ -189,8 +215,23 @@ public class BootstrapRunner {
UnitOfWorkPort unitOfWorkPort = UnitOfWorkPort unitOfWorkPort =
new SqliteUnitOfWorkAdapter(jdbcUrl); new SqliteUnitOfWorkAdapter(jdbcUrl);
ProcessingLogger coordinatorLogger = new Log4jProcessingLogger(DocumentProcessingCoordinator.class); ProcessingLogger coordinatorLogger = new Log4jProcessingLogger(DocumentProcessingCoordinator.class);
TargetFolderPort targetFolderPort = new FilesystemTargetFolderAdapter(startConfig.targetFolder());
TargetFileCopyPort targetFileCopyPort = new FilesystemTargetFileCopyAdapter(startConfig.targetFolder());
DocumentProcessingCoordinator documentProcessingCoordinator = DocumentProcessingCoordinator documentProcessingCoordinator =
new DocumentProcessingCoordinator(documentRecordRepository, processingAttemptRepository, unitOfWorkPort, coordinatorLogger); new DocumentProcessingCoordinator(documentRecordRepository, processingAttemptRepository, unitOfWorkPort, targetFolderPort, targetFileCopyPort, coordinatorLogger);
// Wire AI naming pipeline
AiInvocationPort aiInvocationPort = new OpenAiHttpAdapter(startConfig);
PromptPort promptPort = new FilesystemPromptPortAdapter(startConfig.promptTemplateFile());
ClockPort clockPort = new SystemClockAdapter();
AiResponseValidator aiResponseValidator = new AiResponseValidator(clockPort);
AiNamingService aiNamingService = new AiNamingService(
aiInvocationPort,
promptPort,
aiResponseValidator,
startConfig.apiModel(),
startConfig.maxTextCharacters());
ProcessingLogger useCaseLogger = new Log4jProcessingLogger(DefaultBatchRunProcessingUseCase.class); ProcessingLogger useCaseLogger = new Log4jProcessingLogger(DefaultBatchRunProcessingUseCase.class);
return new DefaultBatchRunProcessingUseCase( return new DefaultBatchRunProcessingUseCase(
runtimeConfig, runtimeConfig,
@@ -199,6 +240,7 @@ public class BootstrapRunner {
new PdfTextExtractionPortAdapter(), new PdfTextExtractionPortAdapter(),
fingerprintPort, fingerprintPort,
documentProcessingCoordinator, documentProcessingCoordinator,
aiNamingService,
useCaseLogger); useCaseLogger);
}; };
this.commandFactory = SchedulerBatchCommand::new; this.commandFactory = SchedulerBatchCommand::new;
@@ -272,8 +314,17 @@ public class BootstrapRunner {
/** /**
* Loads configuration via {@link ConfigurationPort} and validates it via * Loads configuration via {@link ConfigurationPort} and validates it via
* {@link StartConfigurationValidator}. Validation includes checking that the * {@link StartConfigurationValidator}.
* {@code sqlite.file} parent directory exists or is technically creatable. * <p>
* Validation includes:
* <ul>
* <li>{@code source.folder}: must exist, be a directory, and be readable.</li>
* <li>{@code target.folder}: must exist as a writable directory, or be technically
* creatable (validator attempts {@code Files.createDirectories} if absent;
* failure here is a hard startup error).</li>
* <li>{@code sqlite.file}: parent directory must exist.</li>
* <li>All numeric and URI constraints.</li>
* </ul>
*/ */
private StartConfiguration loadAndValidateConfiguration() { private StartConfiguration loadAndValidateConfiguration() {
ConfigurationPort configPort = configPortFactory.create(); ConfigurationPort configPort = configPortFactory.create();

View File

@@ -28,11 +28,15 @@
* Startup sequence: * Startup sequence:
* <ul> * <ul>
* <li>Load and validate complete startup configuration from properties file and environment variables</li> * <li>Load and validate complete startup configuration from properties file and environment variables</li>
* <li>Initialize SQLite persistence schema via {@link de.gecheckt.pdf.umbenenner.application.port.out.PersistenceSchemaInitializationPort}, * <li>Validate target folder availability and write access; create target folder if absent
* (failure is a hard startup error)</li>
* <li>Initialize SQLite persistence schema (including target-copy schema evolution) via
* {@link de.gecheckt.pdf.umbenenner.application.port.out.PersistenceSchemaInitializationPort},
* ensuring the database is ready before any batch processing</li> * ensuring the database is ready before any batch processing</li>
* <li>Schema initialization failure is treated as a hard bootstrap error and causes exit code 1</li> * <li>Schema initialization failure is treated as a hard bootstrap error and causes exit code 1</li>
* <li>Create run lock adapter and acquire exclusive lock</li> * <li>Create run lock adapter and acquire exclusive lock</li>
* <li>Wire all outbound adapters (document candidates, PDF extraction, fingerprint, persistence, logging)</li> * <li>Wire all outbound adapters (document candidates, PDF extraction, fingerprint, persistence,
* target folder duplicate resolution, target file copy, logging)</li>
* <li>Wire and invoke the batch processing CLI adapter</li> * <li>Wire and invoke the batch processing CLI adapter</li>
* <li>Map batch outcome to process exit code</li> * <li>Map batch outcome to process exit code</li>
* </ul> * </ul>

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> * </ul>
*/ */
public sealed interface DocumentProcessingOutcome public sealed interface DocumentProcessingOutcome
permits PreCheckPassed, PreCheckFailed, TechnicalDocumentError { permits PreCheckPassed, PreCheckFailed, TechnicalDocumentError,
NamingProposalReady, AiTechnicalFailure, AiFunctionalFailure {
// Marker interface; concrete implementations define structure // 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.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.time.LocalDate;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
@@ -108,4 +109,166 @@ class DocumentProcessingOutcomeTest {
assertInstanceOf(DocumentProcessingOutcome.class, outcome); 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));
}
} }

78
run-m6.ps1 Normal file
View File

@@ -0,0 +1,78 @@
# run-m6.ps1
# Fuehrt alle M6-Arbeitspakete sequenziell aus.
# Nach jedem AP wird der Build geprueft. Bei Fehler wird abgebrochen.
# Ausfuehren im Projektroot: .\run-m6.ps1
param(
[int]$StartAp = 1,
[int]$EndAp = 8,
[string]$Model = "claude-sonnet-4-6",
[string]$Workpackage = "M6"
)
$ErrorActionPreference = "Stop"
$BuildCmd = ".\mvnw.cmd clean verify -pl pdf-umbenenner-domain,pdf-umbenenner-application,pdf-umbenenner-adapter-out,pdf-umbenenner-adapter-in-cli,pdf-umbenenner-bootstrap --also-make"
function Get-Prompt {
param([int]$Ap)
$baseline = ""
if ($Ap -gt 1) {
$prev = $Ap - 1
$baseline = "AP-001 bis AP-00$prev sind bereits abgeschlossen und bilden die Baseline. "
}
$apFormatted = $Ap.ToString("D3")
return "Lies CLAUDE.md und 'docs/workpackages/$Workpackage - Arbeitspakete.md' vollständig. ${baseline}Implementiere ausschließlich AP-$apFormatted gemäß WORKFLOW.md."
}
Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " $Workpackage Automatisierung" -ForegroundColor Cyan
Write-Host " Modell : $Model" -ForegroundColor Cyan
Write-Host " APs : $StartAp bis $EndAp" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
for ($ap = $StartAp; $ap -le $EndAp; $ap++) {
$apFormatted = $ap.ToString("D3")
Write-Host "----------------------------------------" -ForegroundColor Yellow
Write-Host " Starte AP-$apFormatted" -ForegroundColor Yellow
Write-Host "----------------------------------------" -ForegroundColor Yellow
$prompt = Get-Prompt -Ap $ap
# Claude Code ausfuehren
$claudeArgs = @("--model", $Model, "--dangerously-skip-permissions", "--print", $prompt)
& claude @claudeArgs
if ($LASTEXITCODE -ne 0) {
Write-Host ""
Write-Host "[FEHLER] Claude Code ist bei AP-$apFormatted fehlgeschlagen (Exit $LASTEXITCODE)." -ForegroundColor Red
Write-Host "Bitte AP-$apFormatted manuell pruefen und das Skript mit -StartAp $ap neu starten." -ForegroundColor Red
exit 1
}
# Build pruefen
Write-Host ""
Write-Host "[BUILD] Pruefe Build nach AP-$apFormatted ..." -ForegroundColor Cyan
Invoke-Expression $BuildCmd
if ($LASTEXITCODE -ne 0) {
Write-Host ""
Write-Host "[FEHLER] Build nach AP-$apFormatted fehlgeschlagen." -ForegroundColor Red
Write-Host "Bitte den Stand pruefen und das Skript mit -StartAp $ap neu starten." -ForegroundColor Red
exit 1
}
Write-Host ""
Write-Host "[OK] AP-$apFormatted abgeschlossen und Build gruen." -ForegroundColor Green
Write-Host ""
}
Write-Host "========================================" -ForegroundColor Green
Write-Host " $Workpackage vollstaendig abgeschlossen!" -ForegroundColor Green
Write-Host "========================================" -ForegroundColor Green