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>
This commit is contained in:
2026-07-01 18:34:31 +02:00
parent eb970d7c59
commit a3ed5329af
7 changed files with 327 additions and 7 deletions
@@ -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,118 @@
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();
}
System.setProperty(SYSTEM_PROPERTY_KEY, target);
try {
reconfigure();
} catch (RuntimeException e) {
LOG.warn("Log4j2 konnte nicht auf Log-Verzeichnis '{}' umgestellt werden: {}",
target, e.getMessage());
return LogDirectoryChange.unchanged();
}
LOG.info("Log-Verzeichnis zur Laufzeit auf das konfigurierte Verzeichnis umgestellt: {}", target);
return LogDirectoryChange.changedTo(target);
}
/**
* 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,112 @@
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_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);
}
}