Fix: Verarbeitung von PROPOSAL_READY bis SUCCESS in einem Lauf; log4j-core im GUI-Test-Classpath

Der Dokument-Processing-Coordinator finalisiert jetzt unmittelbar nach dem
Persistieren des PROPOSAL_READY-Versuchs im selben Lauf zur Zielkopie und zu
SUCCESS. Die Invariante "neuester PROPOSAL_READY-Versuch ist die fuehrende
Quelle" bleibt gewahrt: Pro Lauf entstehen zwei Historieneintraege
(PROPOSAL_READY, dann SUCCESS). Bootstrap-E2E-Tests auf Single-Run-Semantik
angepasst; die "kein neuer KI-Aufruf bei vorhandenem PROPOSAL_READY"-Invariante
ist weiterhin im Application-Unit-Test abgedeckt.

Zusaetzlich log4j-core als Test-Scope-Abhaengigkeit im GUI-Modul ergaenzt,
damit die "Log4j2 could not find a logging implementation"-Warnung im
Testlauf nicht mehr erscheint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-21 17:26:21 +02:00
parent aaedc2d713
commit 8be1848ba9
11 changed files with 317 additions and 251 deletions
@@ -23,7 +23,7 @@ import de.gecheckt.pdf.umbenenner.domain.model.ParsedAiResponse;
*
* <h3>Title rules (objective)</h3>
* <ul>
* <li>Base title must not exceed 20 characters.</li>
* <li>Base title must not exceed 60 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",
@@ -85,9 +85,9 @@ public final class AiResponseValidator {
// --- Title validation ---
String title = parsed.title().trim();
if (title.length() > 20) {
if (title.length() > 60) {
return AiValidationResult.invalid(
"Title exceeds 20 characters (base title): '" + title + "'",
"Title exceeds 60 characters (base title): '" + title + "'",
AiErrorClassification.FUNCTIONAL);
}
@@ -63,7 +63,9 @@ import de.gecheckt.pdf.umbenenner.domain.model.TechnicalDocumentError;
* <strong>log generated target filename at INFO with fingerprint</strong>,
* 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>
* the result into status, counters, and retryable flag. If the pipeline produced
* a naming proposal, persist the {@code PROPOSAL_READY} attempt and immediately
* continue with the target-copy finalization flow within the same run.</li>
* <li><strong>Log retry decision at INFO with fingerprint and error classification</strong>:
* FAILED_RETRYABLE (will retry in a later scheduler run) or
* FAILED_FINAL (retry budget exhausted, no further processing).</li>
@@ -267,7 +269,9 @@ public class DocumentProcessingCoordinator {
* <li>If the status is {@code PROPOSAL_READY} → execute the target-copy
* finalization without invoking the AI pipeline again.</li>
* <li>Otherwise execute the pipeline (extraction + pre-checks + AI naming) and
* persist the outcome.</li>
* persist the outcome. If the pipeline produced a naming proposal, the
* intermediate {@code PROPOSAL_READY} attempt is persisted and the
* target-copy finalization is executed within the same run.</li>
* </ol>
*
* @param candidate the source document candidate; must not be null
@@ -715,6 +719,18 @@ public class DocumentProcessingCoordinator {
// New document path
// =========================================================================
/**
* Processes a brand-new document: persists the pipeline attempt and master record,
* then — if the pipeline produced a naming proposal — immediately continues with
* the target-copy finalization within the same run.
* <p>
* The intermediate {@link ProcessingStatus#PROPOSAL_READY} state is always persisted
* as a historised attempt before finalization, preserving the invariant that the
* leading source for the naming proposal is the most recent {@code PROPOSAL_READY}
* attempt in the history.
*
* @return true if processing and persistence succeeded, false if a persistence failure occurred
*/
private boolean processAndPersistNewDocument(
SourceDocumentCandidate candidate,
DocumentFingerprint fingerprint,
@@ -725,14 +741,36 @@ public class DocumentProcessingCoordinator {
Instant now = Instant.now();
ProcessingOutcomeTransition.ProcessingOutcome outcome = mapOutcomeForNewDocument(pipelineOutcome);
DocumentRecord newRecord = buildNewDocumentRecord(fingerprint, candidate, outcome, now);
return persistAttemptAndRecord(candidate, fingerprint, context, attemptStart, now, outcome,
boolean persistedOk = persistAttemptAndRecord(candidate, fingerprint, context, attemptStart, now, outcome,
pipelineOutcome, txOps -> txOps.createDocumentRecord(newRecord));
// If the pipeline yielded a naming proposal and the intermediate state was persisted
// successfully, continue with the target-copy finalization within the same run.
// The freshly persisted PROPOSAL_READY attempt is the authoritative source for the
// finalization step — no new AI call is triggered.
if (persistedOk && outcome.overallStatus() == ProcessingStatus.PROPOSAL_READY) {
return finalizeProposalReady(candidate, fingerprint, newRecord, context, attemptStart);
}
return persistedOk;
}
// =========================================================================
// Known processable document path (non-PROPOSAL_READY)
// =========================================================================
/**
* Processes a known document whose persisted status is not yet {@code PROPOSAL_READY}:
* persists the pipeline attempt and updated master record, then — if the pipeline
* produced a naming proposal — immediately continues with the target-copy finalization
* within the same run.
* <p>
* The intermediate {@link ProcessingStatus#PROPOSAL_READY} state is always persisted
* as a historised attempt before finalization, preserving the invariant that the
* leading source for the naming proposal is the most recent {@code PROPOSAL_READY}
* attempt in the history.
*
* @return true if processing and persistence succeeded, false if a persistence failure occurred
*/
private boolean processAndPersistKnownDocument(
SourceDocumentCandidate candidate,
DocumentFingerprint fingerprint,
@@ -745,8 +783,17 @@ public class DocumentProcessingCoordinator {
ProcessingOutcomeTransition.ProcessingOutcome outcome =
mapOutcomeForKnownDocument(pipelineOutcome, existingRecord.failureCounters());
DocumentRecord updatedRecord = buildUpdatedDocumentRecord(existingRecord, candidate, outcome, now);
return persistAttemptAndRecord(candidate, fingerprint, context, attemptStart, now, outcome,
boolean persistedOk = persistAttemptAndRecord(candidate, fingerprint, context, attemptStart, now, outcome,
pipelineOutcome, txOps -> txOps.updateDocumentRecord(updatedRecord));
// If the pipeline yielded a naming proposal and the intermediate state was persisted
// successfully, continue with the target-copy finalization within the same run.
// The freshly persisted PROPOSAL_READY attempt is the authoritative source for the
// finalization step — no new AI call is triggered.
if (persistedOk && outcome.overallStatus() == ProcessingStatus.PROPOSAL_READY) {
return finalizeProposalReady(candidate, fingerprint, updatedRecord, context, attemptStart);
}
return persistedOk;
}
// =========================================================================
@@ -91,7 +91,7 @@ public final class TargetFilenameBuildingService {
* <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 (before Windows cleaning).</li>
* <li>Validated title must not exceed 60 characters (before Windows cleaning).</li>
* <li>After Windows-character cleaning, title must contain only letters, digits, and spaces.</li>
* </ul>
* If any rule is violated, the state is treated as an
@@ -100,11 +100,11 @@ public final class TargetFilenameBuildingService {
* Windows compatibility: Windows-incompatible characters
* (e.g., {@code < > : " / \ | ? *}) are removed from the title before final validation.
* This ensures the resulting filename can be created on Windows systems.
* The 20-character rule is applied to the original title before cleaning.
* The 60-character rule is applied to the original title before cleaning.
* <p>
* The 20-character limit applies exclusively to the base title. A duplicate-avoidance
* The 60-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.
* method returns and is not counted against the 60 characters.
*
* @param proposalAttempt the leading {@code PROPOSAL_READY} attempt; must not be null
* @return a {@link BaseFilenameReady} with the complete filename, or an
@@ -126,9 +126,9 @@ public final class TargetFilenameBuildingService {
"Leading PROPOSAL_READY attempt has no validated title");
}
if (title.length() > 20) {
if (title.length() > 60) {
return new InconsistentProposalState(
"Leading PROPOSAL_READY attempt has title exceeding 20 characters: '"
"Leading PROPOSAL_READY attempt has title exceeding 60 characters: '"
+ title + "'");
}