3 Commits

Author SHA1 Message Date
marcus c7a40298df fix: Codex-Review-Befunde zu Verlauf-Detailanzeige und Log-Umstellung
- RuntimeLogDirectoryReconfigurer: Bei fehlgeschlagenem Log4j2-Reconfigure wird die
  System-Property log.directory auf den vorherigen Wert zurückgesetzt, damit ein
  späterer Versuch nicht faelschlich als "bereits aktiv" uebersprungen wird. Test ergaenzt.
- GuiHistoryTab: Beim Detailwechsel wird die KI-Rohantwort zurückgesetzt; verspätet
  eintreffende Detail-Ergebnisse einer inzwischen gewechselten/abgewählten Selektion
  werden über einen Fingerprint-Abgleich verworfen (kein "A unter B", keine dauerhaft
  sichtbare fremde Rohantwort bei Ladefehler).
- GuiHistoryTab: Der technische Fehler-Tooltip wird bei nicht-fehlerhaften Datensätzen
  auf den Standardtext zurückgesetzt (keine fremde Fehlermeldung mehr sichtbar).
- AiFailureMessageTranslator: praezisere Meldung ("kein gueltiges JSON oder ein
  Pflichtfeld fehlt").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 18:53:46 +02:00
marcus a3ed5329af feat: KI-Rohantwort im Verlauf-Tab, echte Fehlermeldung, GUI-Log-Verzeichnis, Prompt-Härtung
Diagnose-Verbesserungen rund um die KI-Verarbeitung:

- Verlauf-Tab zeigt die vollständige KI-Rohantwort des ausgewählten Versuchs an
  (neues Feld "KI-Rohantwort"). Die technische Original-Fehlermeldung ist zusätzlich
  als Tooltip der Fehlerursache verfügbar.
- AiFailureMessageTranslator: eigene, freundliche Meldung für strukturell nicht
  verwertbare KI-Antworten (NOT_JSON_OBJECT/INVALID_JSON u.a.); der bisher generische
  Fallback zeigt jetzt den echten technischen Grund an, statt ihn zu verbergen.
- Log-Verzeichnis wird im GUI-Modus zur Laufzeit auf das in der geladenen Konfiguration
  konfigurierte Verzeichnis umgestellt (RuntimeLogDirectoryReconfigurer, Best-Effort,
  Log4j2-Reconfigure). Bisher landeten GUI-Logs im user.home-Fallback, weil die GUI
  ohne --config startet. Der headless Pfad bleibt unverändert.
- Standard-Prompt: explizit keine Markdown-Codeblöcke/Zäune, nur reines JSON;
  Unsicherheiten gehören ins reasoning-Feld.

Tests fuer alle vier Punkte ergaenzt/aktualisiert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 18:34:31 +02:00
marcus eb970d7c59 fix: KI-Antwort mit umgebendem Text/Markdown tolerant parsen
AiResponseParser lehnte jede Antwort ab, die nach dem Trimmen nicht mit '{'
begann und mit '}' endete (NOT_JSON_OBJECT). claude-sonnet-5 liefert das
JSON gelegentlich mit Einleitungstext oder in ```json ...```-Codeblöcken; solche
Antworten scheiterten dann als transienter Fehler und wurden endlos wiederholt
(betroffen u.a. Scan260701074355.pdf, sporadisch weitere Dokumente).

Der Parser löst nun das erste ausbalancierte JSON-Objekt aus der Antwort heraus
(Klammerzählung mit Beachtung von String-Literalen und Escapes) und ignoriert
umgebenden Fließtext sowie Markdown-Codeblöcke. Reine JSON-Objekt-Antworten
verhalten sich unverändert; ein Top-Level-Array (oder sonstige Nicht-Objekt-
Struktur) wird weiterhin als NOT_JSON_OBJECT abgelehnt. Regressionstests ergänzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 18:07:13 +02:00
9 changed files with 548 additions and 32 deletions
@@ -25,7 +25,9 @@ public final class AiFailureMessageTranslator {
* technische Fehlerbeschreibung.
* <p>
* Ist {@code technicalMessage} {@code null} oder leer, wird der allgemeine
* Fallback-Text zurückgegeben.
* Fallback-Text zurückgegeben. Für eine nicht leere, aber keinem Muster zuordenbare
* Meldung wird die technische Originalmeldung sichtbar angehängt, damit die tatsächliche
* Ursache nicht hinter einem generischen Platzhalter verborgen bleibt.
*
* @param technicalMessage die rohe technische Fehlermeldung; darf {@code null} sein
* @return eine nicht-leere deutsche Benutzerfehlermeldung ohne führendes Warnsymbol
@@ -75,7 +77,19 @@ public final class AiFailureMessageTranslator {
return "KI-Dienst nicht erreichbar. Bitte Verbindung und Konfiguration prüfen.";
}
return "Verarbeitung fehlgeschlagen. Bitte Konfiguration prüfen und ggf. erneut verarbeiten.";
// KI-Antwort strukturell nicht verwertbar (kein/ungültiges JSON, Zusatztext, Pflichtfeld fehlt)
if (lower.contains("not_json_object") || lower.contains("not a pure json")
|| lower.contains("invalid_json") || lower.contains("could not be parsed")
|| lower.contains("empty_response")
|| lower.contains("missing_title") || lower.contains("blank_title")
|| lower.contains("missing_reasoning")) {
return "KI-Antwort war nicht im erwarteten Format (kein gültiges JSON oder ein Pflichtfeld fehlt). "
+ "Bitte erneut verarbeiten; hält der Fehler an, den Prompt anpassen.";
}
// Unbekannter technischer Fehler: die echte technische Meldung sichtbar machen,
// statt sie hinter einem generischen Platzhalter zu verbergen.
return "Verarbeitung fehlgeschlagen. Technischer Grund: " + technicalMessage;
}
/**
@@ -63,7 +63,8 @@ import javafx.scene.layout.VBox;
* <p>
* Zeigt alle jemals verarbeiteten Dokumente aus der SQLite-Datenbank in einer
* zweispaltigen Ansicht: links eine filterbare Dokumentenliste (~55%),
* rechts ein Detailbereich mit Stammsatz, Versuchstabelle und KI-Begründung (~45%).
* rechts ein Detailbereich mit Stammsatz, Versuchstabelle, KI-Begründung und
* KI-Rohantwort (~45%).
* Das Suchfeld ist mit einem 300-ms-Debounce ausgestattet (Live-Filter).
*
* <h2>Layout</h2>
@@ -101,6 +102,7 @@ public final class GuiHistoryTab {
"Weitere Einträge vorhanden Filter verwenden um die Trefferliste einzuschränken.";
private static final String DETAIL_PLACEHOLDER = "Dokument auswählen für Details";
private static final String NO_REASONING_TEXT = "Kein KI-Reasoning für diesen Versuch vorhanden.";
private static final String NO_RAW_RESPONSE_TEXT = "Keine KI-Rohantwort für diesen Versuch gespeichert.";
private static final String LOADING_TEXT = "Wird geladen …";
private static final String LAUF_AKTIV_HINWEIS = "Aktion während Verarbeitungslauf nicht möglich.";
@@ -142,6 +144,14 @@ public final class GuiHistoryTab {
private final ObservableList<ProcessingAttempt> attemptsItems = FXCollections.observableArrayList();
private final TextArea failureArea = new TextArea();
private final TextArea reasoningArea = new TextArea();
private final TextArea rawResponseArea = new TextArea();
/**
* SHA-256 des zuletzt angeforderten Detail-Dokuments. Dient dazu, verspätet
* eintreffende Detail-Ergebnisse einer inzwischen abgewählten oder gewechselten
* Selektion zu verwerfen. Zugriff ausschließlich auf dem JavaFX Application Thread.
*/
private String pendingDetailFingerprintHex;
private final Button resetButton = new Button("Status zurücksetzen");
private final Button deleteButton = new Button("Eintrag löschen");
@@ -452,11 +462,23 @@ public final class GuiHistoryTab {
Label reasoningTitle = new Label("KI-Begründung (ausgewählter Versuch)");
reasoningTitle.setStyle(BOLD_STYLE);
// KI-Rohantwort (vollständige, unveränderte Antwort der KI für den Versuch)
rawResponseArea.setEditable(false);
rawResponseArea.setWrapText(true);
rawResponseArea.setPrefRowCount(6);
rawResponseArea.setPromptText(NO_RAW_RESPONSE_TEXT);
rawResponseArea.setTooltip(new Tooltip(
"Die vollständige, unveränderte Antwort der KI für diesen Versuch "
+ "inklusive eventuellem Text um das JSON herum. Nützlich zur Fehlersuche."));
Label rawResponseTitle = new Label("KI-Rohantwort (ausgewählter Versuch)");
rawResponseTitle.setStyle(BOLD_STYLE);
VBox rightPane = new VBox(8,
detailTitle, detailGrid,
attemptsTitle, attemptsTable,
failureTitle, failureArea,
reasoningTitle, reasoningArea);
reasoningTitle, reasoningArea,
rawResponseTitle, rawResponseArea);
rightPane.setPadding(new Insets(4, 8, 4, 4));
VBox.setVgrow(attemptsTable, Priority.ALWAYS);
@@ -700,7 +722,10 @@ public final class GuiHistoryTab {
}
private void loadDetails(DocumentFingerprint fingerprint) {
pendingDetailFingerprintHex = fingerprint.sha256Hex();
reasoningArea.setText(LOADING_TEXT);
rawResponseArea.setText("");
rawResponseArea.setPromptText(NO_RAW_RESPONSE_TEXT);
attemptsItems.clear();
clearDetailFields();
@@ -714,6 +739,9 @@ public final class GuiHistoryTab {
try {
Optional<HistoryDetailsResult> result = detailsPort.loadDetails(configPath, fingerprint);
Platform.runLater(() -> {
if (isSupersededDetail(fingerprint)) {
return;
}
if (result.isEmpty()) {
clearDetailPane();
statusBarLabel.setText("Eintrag nicht mehr vorhanden.");
@@ -725,6 +753,10 @@ public final class GuiHistoryTab {
LOG.error("Fehler beim Laden der Dokumentdetails für {}: {}",
fingerprint.sha256Hex(), ex.getMessage(), ex);
Platform.runLater(() -> {
if (isSupersededDetail(fingerprint)) {
return;
}
clearDetailPane();
reasoningArea.setText("Fehler beim Laden der Details: " + ex.getMessage());
statusBarLabel.setText("Fehler beim Laden der Details.");
});
@@ -913,15 +945,19 @@ public final class GuiHistoryTab {
ProcessingAttempt last = attempts.get(attempts.size() - 1);
attemptsTable.getSelectionModel().select(last);
showReasoning(last);
showRawResponse(last);
} else {
reasoningArea.setText("");
reasoningArea.setPromptText(NO_REASONING_TEXT);
rawResponseArea.setText("");
rawResponseArea.setPromptText(NO_RAW_RESPONSE_TEXT);
}
}
private void onAttemptSelected(ProcessingAttempt attempt) {
if (attempt != null) {
showReasoning(attempt);
showRawResponse(attempt);
}
}
@@ -938,6 +974,7 @@ public final class GuiHistoryTab {
if (!failureRelevant || attempts.isEmpty()) {
failureArea.setText("");
failureArea.setPromptText("Keine Fehlerdetails für diesen Status.");
failureArea.setTooltip(new Tooltip(GuiTooltipTexts.VERLAUF_FAILURE_AREA));
return;
}
@@ -954,6 +991,11 @@ public final class GuiHistoryTab {
failureArea.setText(failureMessage != null
? AiFailureMessageTranslator.translate(failureMessage) : "");
failureArea.setPromptText(NO_ERROR_DETAILS_MSG);
// Die technische Originalmeldung als Tooltip bereitstellen, damit die exakte
// Ursache jederzeit einsehbar ist, ohne den lesbaren Anzeigetext zu überladen.
failureArea.setTooltip(new Tooltip(failureMessage != null
? failureMessage
: GuiTooltipTexts.VERLAUF_FAILURE_AREA));
}
private void showReasoning(ProcessingAttempt attempt) {
@@ -967,12 +1009,46 @@ public final class GuiHistoryTab {
}
}
/**
* Zeigt die vollständige KI-Rohantwort des ausgewählten Versuchs an.
* <p>
* Enthält der Versuch keine gespeicherte Rohantwort (z. B. Skip-/Pre-Check-Versuche
* oder Netzwerkfehler vor der Antwort), wird ein Platzhaltertext angezeigt.
*
* @param attempt der ausgewählte Verarbeitungsversuch; darf nicht {@code null} sein
*/
private void showRawResponse(ProcessingAttempt attempt) {
String raw = attempt.aiRawResponse();
if (raw != null && !raw.isBlank()) {
rawResponseArea.setText(raw);
rawResponseArea.setPromptText("");
} else {
rawResponseArea.setText("");
rawResponseArea.setPromptText(NO_RAW_RESPONSE_TEXT);
}
}
/**
* Prüft, ob ein asynchron geladenes Detail-Ergebnis inzwischen überholt ist, weil
* mittlerweile ein anderes (oder kein) Dokument ausgewählt wurde.
*
* @param fingerprint der Fingerprint, für den das Ergebnis geladen wurde
* @return {@code true}, wenn das Ergebnis verworfen werden soll
*/
private boolean isSupersededDetail(DocumentFingerprint fingerprint) {
return !fingerprint.sha256Hex().equals(pendingDetailFingerprintHex);
}
private void clearDetailPane() {
pendingDetailFingerprintHex = null;
clearDetailFields();
attemptsItems.clear();
failureArea.setText("");
failureArea.setPromptText(NO_ERROR_DETAILS_MSG);
failureArea.setTooltip(new Tooltip(GuiTooltipTexts.VERLAUF_FAILURE_AREA));
reasoningArea.setText(DETAIL_PLACEHOLDER);
rawResponseArea.setText("");
rawResponseArea.setPromptText(NO_RAW_RESPONSE_TEXT);
}
private void clearDetailFields() {
@@ -164,9 +164,30 @@ class AiFailureMessageTranslatorTest {
// -------------------------------------------------------------------------
@Test
void unbekannterFehler_liefertFallback() {
void unbekannterFehler_haengtTechnischeMeldungAn() {
String result = AiFailureMessageTranslator.translate("some completely unknown error text");
assertEquals("Verarbeitung fehlgeschlagen. Bitte Konfiguration prüfen und ggf. erneut verarbeiten.", result);
assertEquals("Verarbeitung fehlgeschlagen. Technischer Grund: some completely unknown error text",
result);
}
// -------------------------------------------------------------------------
// KI-Antwort strukturell nicht verwertbar (JSON/Parse)
// -------------------------------------------------------------------------
@Test
void notJsonObject_liefertFormatMeldung() {
String result = AiFailureMessageTranslator.translate(
"AI response could not be parsed [NOT_JSON_OBJECT]: AI response is not a pure JSON object"
+ " (contains extra text or is not an object)");
assertTrue(result.contains("nicht im erwarteten Format"), result);
assertTrue(result.contains("JSON"), result);
}
@Test
void invalidJson_liefertFormatMeldung() {
String result = AiFailureMessageTranslator.translate(
"AI response could not be parsed [INVALID_JSON]: AI response is not valid JSON: ...");
assertTrue(result.contains("nicht im erwarteten Format"), result);
}
@Test
@@ -14,10 +14,10 @@ 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
* This parser enforces the technical contract: the AI must provide 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.
* and an optional {@code date} field. Surrounding free-text or Markdown code fences around
* that JSON object are tolerated; the object is extracted before parsing.
*
* <h2>Parsing rules</h2>
* <ul>
@@ -26,9 +26,10 @@ import de.gecheckt.pdf.umbenenner.domain.model.ParsedAiResponse;
* <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>
* <li>Leading or trailing free-text and Markdown code fences around the JSON object are
* tolerated: the first balanced JSON object is extracted and parsed. A response that
* contains no JSON object, or where an array (or other non-object) appears at the top
* level, is rejected as {@code NOT_JSON_OBJECT}.</li>
* </ul>
*
* <h2>Architecture boundary</h2>
@@ -67,11 +68,11 @@ public final class AiResponseParser {
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("}")) {
// Extract the JSON object from the response even when it is surrounded by free-text
// or Markdown code fences. Pure JSON-object responses are unaffected. A top-level
// array (or other non-object structure) is still rejected.
String jsonObject = extractJsonObject(body);
if (jsonObject == null) {
return new AiResponseParsingFailure(
"NOT_JSON_OBJECT",
"AI response is not a pure JSON object (contains extra text or is not an object)");
@@ -79,7 +80,7 @@ public final class AiResponseParser {
JSONObject json;
try {
json = new JSONObject(trimmed);
json = new JSONObject(jsonObject);
} catch (JSONException e) {
return new AiResponseParsingFailure("INVALID_JSON", "AI response is not valid JSON: " + e.getMessage());
}
@@ -108,4 +109,74 @@ public final class AiResponseParser {
ParsedAiResponse parsed = ParsedAiResponse.of(title, reasoning, dateString);
return new AiResponseParsingSuccess(parsed);
}
/**
* Extracts the first complete JSON object from the response text.
* <p>
* Tolerates leading and trailing free-text as well as Markdown code fences
* (e.g. {@code ```json … ```}) by reading from the first {@code '{'} up to its matching
* {@code '}'}. Brace counting ignores curly braces inside JSON string literals and honours
* escape sequences.
* <p>
* If a {@code '['} appears at the top level before the first {@code '{'} the response is
* treated as an array (or other non-object structure) and {@code null} is returned.
*
* @param body the raw response body; must not be {@code null} or blank
* @return the substring containing the JSON object, or {@code null} if no complete object was found
*/
private static String extractJsonObject(String body) {
int firstBrace = body.indexOf('{');
if (firstBrace < 0) {
return null;
}
int firstBracket = body.indexOf('[');
if (firstBracket >= 0 && firstBracket < firstBrace) {
// Array or other non-object structure at the top level.
return null;
}
return balancedObject(body, firstBrace);
}
/**
* Reads the balanced JSON object starting at {@code start} (the index of an opening {@code '{'}).
* <p>
* Counts curly braces while respecting string literals and escape sequences, and returns the
* substring from the opening {@code '{'} up to and including its matching {@code '}'}.
*
* @param body the text to scan
* @param start the index of the opening {@code '{'}
* @return the object substring including both braces, or {@code null} if no balanced object was found
*/
private static String balancedObject(String body, int start) {
boolean inString = false;
boolean escaped = false;
int depth = 0;
for (int i = start; i < body.length(); i++) {
char c = body.charAt(i);
if (inString) {
if (escaped) {
escaped = false;
} else if (c == '\\') {
escaped = true;
} else if (c == '"') {
inString = false;
}
continue;
}
switch (c) {
case '"' -> inString = true;
case '{' -> depth++;
case '}' -> {
depth--;
if (depth == 0) {
return body.substring(start, i + 1);
}
}
default -> {
// ordinary character outside a string ignored
}
}
}
return null;
}
}
@@ -89,7 +89,11 @@ public final class DefaultPromptTemplate {
- Keine Sonderzeichen außer Leerzeichen im Titel.
- Eigennamen bleiben unverändert.
- Umlaute und ß sind erlaubt.
- Kein Text außerhalb des JSON-Objekts.
- Kein Text außerhalb des JSON-Objekts. Keine Markdown-Codeblöcke und keine
```-Zäune, keine Einleitung und keine Nachbemerkung. Gib ausschließlich das
reine JSON-Objekt aus, beginnend mit { und endend mit }.
- Unsicherheiten, Einschränkungen oder Hinweise gehören ausschließlich in das
Feld "reasoning", niemals in Text außerhalb des JSON-Objekts.
""";
return template.replace(MAX_TITLE_LENGTH_PLACEHOLDER, String.valueOf(maxTitleLength));
}
@@ -15,7 +15,8 @@ import de.gecheckt.pdf.umbenenner.domain.model.ParsedAiResponse;
* 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.
* optional date, extra fields, extraction of JSON objects embedded in surrounding
* text or Markdown code fences, and rejection of non-JSON or non-object responses.
*/
class AiResponseParserTest {
@@ -96,6 +97,81 @@ class AiResponseParserTest {
assertThat(parsed.dateString()).isEmpty();
}
// -------------------------------------------------------------------------
// Tolerance: JSON object embedded in surrounding text / Markdown fences
// -------------------------------------------------------------------------
@Test
void parse_jsonWrappedInMarkdownFence_extractsObject() {
AiRawResponse raw = new AiRawResponse(
"```json\n{\"title\":\"Rechnung\",\"reasoning\":\"Invoice\"}\n```");
AiResponseParsingResult result = AiResponseParser.parse(raw);
assertThat(result).isInstanceOf(AiResponseParsingSuccess.class);
ParsedAiResponse parsed = ((AiResponseParsingSuccess) result).response();
assertThat(parsed.title()).isEqualTo("Rechnung");
assertThat(parsed.reasoning()).isEqualTo("Invoice");
}
@Test
void parse_jsonWithLeadingProse_extractsObjectIncludingDate() {
AiRawResponse raw = new AiRawResponse(
"Hier ist das Ergebnis:\n{\"title\":\"Stromabrechnung\",\"reasoning\":\"r\",\"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.dateString()).contains("2026-01-15");
}
@Test
void parse_jsonWithTrailingProse_extractsObject() {
AiRawResponse raw = new AiRawResponse(
"{\"title\":\"Vertrag\",\"reasoning\":\"r\"}\n\nHope that helps!");
AiResponseParsingResult result = AiResponseParser.parse(raw);
assertThat(result).isInstanceOf(AiResponseParsingSuccess.class);
assertThat(((AiResponseParsingSuccess) result).response().title()).isEqualTo("Vertrag");
}
@Test
void parse_bracketInsideStringValue_isNotTreatedAsArray() {
AiRawResponse raw = new AiRawResponse(
"{\"title\":\"Rechnung [Entwurf]\",\"reasoning\":\"contains a [ bracket\"}");
AiResponseParsingResult result = AiResponseParser.parse(raw);
assertThat(result).isInstanceOf(AiResponseParsingSuccess.class);
assertThat(((AiResponseParsingSuccess) result).response().title()).isEqualTo("Rechnung [Entwurf]");
}
@Test
void parse_nestedObject_bracesAreBalancedCorrectly() {
AiRawResponse raw = new AiRawResponse(
"prefix {\"title\":\"X\",\"reasoning\":\"r\",\"meta\":{\"a\":1,\"b\":{\"c\":2}}} suffix");
AiResponseParsingResult result = AiResponseParser.parse(raw);
assertThat(result).isInstanceOf(AiResponseParsingSuccess.class);
assertThat(((AiResponseParsingSuccess) result).response().title()).isEqualTo("X");
}
@Test
void parse_unbalancedTruncatedObject_returnsFailure() {
AiRawResponse raw = new AiRawResponse(
"{\"title\":\"X\",\"reasoning\":\"r\"");
AiResponseParsingResult result = AiResponseParser.parse(raw);
assertThat(result).isInstanceOf(AiResponseParsingFailure.class);
assertThat(((AiResponseParsingFailure) result).failureReason())
.isEqualTo("NOT_JSON_OBJECT");
}
// -------------------------------------------------------------------------
// Failure cases structural
// -------------------------------------------------------------------------
@@ -131,18 +207,6 @@ class AiResponseParserTest {
.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\"}]");
@@ -1277,6 +1277,11 @@ public class BootstrapRunner {
try {
migrateConfigurationIfNeeded(configFilePath);
StartConfiguration config = loadAndValidateConfiguration(configFilePath);
// Im GUI-Modus wird ohne --config gestartet; das konfigurierte Log-Verzeichnis
// wird erst hier bekannt. Log4j2 (Best-Effort) darauf umstellen, damit die Logs
// im konfigurierten Verzeichnis landen und nicht im Standard-Fallback.
new de.gecheckt.pdf.umbenenner.bootstrap.startup.RuntimeLogDirectoryReconfigurer()
.apply(config.logDirectory());
initializeSchema(config);
guiApplicationRunContext.set(Optional.of(
new ApplicationRunContext(config, resolveActiveJdbcUrl(config))));
@@ -0,0 +1,131 @@
package de.gecheckt.pdf.umbenenner.bootstrap.startup;
import java.nio.file.Files;
import java.nio.file.Path;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.core.LoggerContext;
/**
* Stellt das Log4j2-Log-Verzeichnis zur Laufzeit auf das in der geladenen Konfiguration
* hinterlegte {@code log.directory} um.
* <p>
* Hintergrund: Im GUI-Modus wird die Anwendung typischerweise <em>ohne</em> {@code --config}
* gestartet. {@link EarlyLogDirectoryInitializer} kann die Property {@code log.directory}
* dann nicht aus der später in der GUI geladenen Konfigurationsdatei ableiten, sodass Log4j2
* auf sein Standard-Log-Verzeichnis zurückfällt. Sobald die GUI eine Konfiguration lädt
* (Auto-Reload beim Start oder „Öffnen"), stellt diese Klasse das Log-Verzeichnis
* nachträglich auf den konfigurierten Wert um, indem sie die System-Property setzt und
* den laufenden {@link LoggerContext} neu konfiguriert (Log4j2 löst {@code ${sys:log.directory}}
* dabei erneut auf).
* <p>
* Die Umstellung ist <strong>Best-Effort</strong>: Fehler (nicht anlegbares Verzeichnis,
* Reconfigure-Fehler) werden protokolliert und geschluckt; das bisherige Log-Verzeichnis
* bleibt in diesem Fall aktiv. Der Programmablauf wird nie unterbrochen.
* <p>
* Diese Klasse betrifft ausschließlich den GUI-Pfad. Der headless Betrieb erhält das
* Log-Verzeichnis unverändert früh über {@link EarlyLogDirectoryInitializer}.
*/
public class RuntimeLogDirectoryReconfigurer {
private static final String SYSTEM_PROPERTY_KEY = "log.directory";
private static final Logger LOG = LogManager.getLogger(RuntimeLogDirectoryReconfigurer.class);
/**
* Erstellt einen neuen Reconfigurer.
*/
public RuntimeLogDirectoryReconfigurer() {
// zustandslos
}
/**
* Ergebnis einer Umstellungsanfrage.
*
* @param changed {@code true}, wenn das Log-Verzeichnis tatsächlich umgestellt wurde
* @param appliedDirectory das nun aktive Log-Verzeichnis, oder {@code null}, wenn nichts geändert wurde
*/
public record LogDirectoryChange(boolean changed, String appliedDirectory) {
private static LogDirectoryChange unchanged() {
return new LogDirectoryChange(false, null);
}
private static LogDirectoryChange changedTo(String appliedDirectory) {
return new LogDirectoryChange(true, appliedDirectory);
}
}
/**
* Stellt das Log-Verzeichnis auf {@code logDirectory} um, sofern nötig und möglich.
* <p>
* Ist {@code logDirectory} {@code null} oder leer, oder entspricht es bereits dem aktiven
* Wert, geschieht nichts. Andernfalls wird das Verzeichnis angelegt (Best-Effort), die
* System-Property {@code log.directory} gesetzt und Log4j2 neu konfiguriert.
*
* @param logDirectory das konfigurierte Log-Verzeichnis; darf {@code null} sein
* @return das Ergebnis der Umstellung; nie {@code null}
*/
public LogDirectoryChange apply(Path logDirectory) {
if (logDirectory == null || logDirectory.toString().isBlank()) {
return LogDirectoryChange.unchanged();
}
String target;
try {
target = logDirectory.toAbsolutePath().toString();
} catch (RuntimeException e) {
LOG.warn("Konfiguriertes Log-Verzeichnis '{}' konnte nicht aufgelöst werden: {} "
+ "bisheriges Log-Verzeichnis bleibt aktiv.", logDirectory, e.getMessage());
return LogDirectoryChange.unchanged();
}
if (target.equals(System.getProperty(SYSTEM_PROPERTY_KEY))) {
return LogDirectoryChange.unchanged();
}
try {
Files.createDirectories(logDirectory.toAbsolutePath());
} catch (Exception e) {
LOG.warn("Konfiguriertes Log-Verzeichnis '{}' konnte nicht angelegt werden: {} "
+ "bisheriges Log-Verzeichnis bleibt aktiv.", target, e.getMessage());
return LogDirectoryChange.unchanged();
}
String previous = System.getProperty(SYSTEM_PROPERTY_KEY);
System.setProperty(SYSTEM_PROPERTY_KEY, target);
try {
reconfigure();
} catch (RuntimeException e) {
// Property auf den vorherigen Wert zurücksetzen: Bleibt sie auf dem neuen (nicht
// aktivierten) Ziel stehen, würde die Idempotenz-Prüfung oben jeden weiteren
// Umstellungsversuch fälschlich als „bereits aktiv" überspringen.
restoreProperty(previous);
LOG.warn("Log4j2 konnte nicht auf Log-Verzeichnis '{}' umgestellt werden: {} "
+ "bisheriges Log-Verzeichnis bleibt aktiv.", target, e.getMessage());
return LogDirectoryChange.unchanged();
}
LOG.info("Log-Verzeichnis zur Laufzeit auf das konfigurierte Verzeichnis umgestellt: {}", target);
return LogDirectoryChange.changedTo(target);
}
private static void restoreProperty(String previous) {
if (previous == null) {
System.clearProperty(SYSTEM_PROPERTY_KEY);
} else {
System.setProperty(SYSTEM_PROPERTY_KEY, previous);
}
}
/**
* Konfiguriert den laufenden Log4j2-{@link LoggerContext} neu, sodass
* {@code ${sys:log.directory}} erneut aufgelöst wird.
* <p>
* Als eigene Methode ausgelegt, damit Tests die Log4j2-Neukonfiguration überschreiben können.
*/
protected void reconfigure() {
LoggerContext ctx = (LoggerContext) LogManager.getContext(false);
ctx.reconfigure();
}
}
@@ -0,0 +1,130 @@
package de.gecheckt.pdf.umbenenner.bootstrap.startup;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import de.gecheckt.pdf.umbenenner.bootstrap.startup.RuntimeLogDirectoryReconfigurer.LogDirectoryChange;
/**
* Tests für {@link RuntimeLogDirectoryReconfigurer}.
*
* <p>Die tatsächliche Log4j2-Neukonfiguration wird über eine Test-Unterklasse unterdrückt,
* damit die Tests den globalen Log4j2-Zustand nicht verändern. Die System-Property
* {@code log.directory} wird pro Test gesichert, geleert und wiederhergestellt.
*/
class RuntimeLogDirectoryReconfigurerTest {
private static final String KEY = "log.directory";
private String originalProperty;
@TempDir
Path tempDir;
/** Variante, die die echte Log4j2-Neukonfiguration durch bloßes Zählen ersetzt. */
private static final class RecordingReconfigurer extends RuntimeLogDirectoryReconfigurer {
private int reconfigureCalls = 0;
@Override
protected void reconfigure() {
reconfigureCalls++;
}
}
@BeforeEach
void clearLogDirectoryProperty() {
originalProperty = System.getProperty(KEY);
System.clearProperty(KEY);
}
@AfterEach
void restoreLogDirectoryProperty() {
if (originalProperty == null) {
System.clearProperty(KEY);
} else {
System.setProperty(KEY, originalProperty);
}
}
@Test
void apply_creatableDirectory_setsPropertyCreatesDirAndReconfigures() {
RecordingReconfigurer reconf = new RecordingReconfigurer();
Path target = tempDir.resolve("logs-sub");
LogDirectoryChange change = reconf.apply(target);
assertTrue(change.changed());
assertNotNull(change.appliedDirectory());
assertTrue(Files.isDirectory(target), "Konfiguriertes Log-Verzeichnis muss angelegt worden sein");
assertEquals(target.toAbsolutePath().toString(), System.getProperty(KEY));
assertEquals(1, reconf.reconfigureCalls);
}
@Test
void apply_nullOrBlank_doesNothing() {
RecordingReconfigurer reconf = new RecordingReconfigurer();
assertFalse(reconf.apply(null).changed());
assertFalse(reconf.apply(Paths.get("")).changed());
assertNull(System.getProperty(KEY));
assertEquals(0, reconf.reconfigureCalls);
}
@Test
void apply_alreadyActiveDirectory_isIdempotent() {
RecordingReconfigurer reconf = new RecordingReconfigurer();
Path target = tempDir.resolve("logs");
System.setProperty(KEY, target.toAbsolutePath().toString());
LogDirectoryChange change = reconf.apply(target);
assertFalse(change.changed(), "Bereits aktives Verzeichnis darf keine Umstellung auslösen");
assertEquals(0, reconf.reconfigureCalls);
}
@Test
void apply_reconfigureFails_restoresPreviousProperty() {
System.setProperty(KEY, "C:/vorher/logs");
RuntimeLogDirectoryReconfigurer failing = new RuntimeLogDirectoryReconfigurer() {
@Override
protected void reconfigure() {
throw new IllegalStateException("reconfigure kaputt");
}
};
Path target = tempDir.resolve("neu");
LogDirectoryChange change = failing.apply(target);
assertFalse(change.changed());
assertEquals("C:/vorher/logs", System.getProperty(KEY),
"Nach fehlgeschlagener Neukonfiguration muss der vorherige Property-Wert wiederhergestellt sein");
}
@Test
void apply_nonCreatableDirectory_leavesPropertyUnchanged() throws IOException {
RecordingReconfigurer reconf = new RecordingReconfigurer();
Path blocker = tempDir.resolve("blocker.txt");
Files.writeString(blocker, "x", StandardCharsets.UTF_8);
Path target = blocker.resolve("sub"); // Unterordner unterhalb einer Datei -> nicht anlegbar
LogDirectoryChange change = reconf.apply(target);
assertFalse(change.changed());
assertNull(System.getProperty(KEY), "Bei Fehlschlag darf keine System-Property gesetzt werden");
assertEquals(0, reconf.reconfigureCalls);
}
}