Validate AI findings against source invoice reports

This commit is contained in:
admin committed 2026-09-11 14:52:59 +02:00
1 parent 5b697ee200
commit 6e1187cdd1
5 files changed
+195 -2

No files matched your search

+5
View File
@@ -144,3 +144,8 @@ Exitcodes:
Die Antwort ist Freitext. Eine automatische fachliche Bewertung und Die Antwort ist Freitext. Eine automatische fachliche Bewertung und
ein verbindliches JSON-Ausgabeformat sind noch nicht implementiert. ein verbindliches JSON-Ausgabeformat sind noch nicht implementiert.
Bei Completed-Berichten werden die Zeilennummern und Rechnungsnummern
der KI-Befunde mit dem ursprünglichen Prüfbericht abgeglichen.
Befunde für dort gültige Zeilen werden zurückgewiesen.
Vertragsverletzungen führen zu Exitcode 2 und keiner Analyse auf stdout.
+22
View File
@@ -213,3 +213,25 @@ Bewertung: festgelegte Kriterien in diesem Lauf bestanden.
- Ablehnungen, abgebrochene Antworten und ungültige Antwortstrukturen - Ablehnungen, abgebrochene Antworten und ungültige Antwortstrukturen
werden im Code behandelt, wurden aber noch nicht gezielt getestet. werden im Code behandelt, wurden aber noch nicht gezielt getestet.
- Einzelne erfolgreiche Läufe belegen keine zuverlässige Wiederholbarkeit. - Einzelne erfolgreiche Läufe belegen keine zuverlässige Wiederholbarkeit.
## Lokaler Berichtsabgleich vom 11.09.2026
InvoiceAnalysisValidator gleicht Befunde bei Completed-Berichten
mit den ursprünglichen Rechnungsprüfergebnissen ab.
Zurückgewiesen werden:
- fehlende oder im Bericht nicht vorhandene Zeilennummern;
- Rechnungsnummern, die nicht zur angegebenen Zeile passen;
- Fehlerbefunde für Zeilen, die die Prüfregeln bestanden haben.
Eine fehlende Rechnungsnummer im Quellbericht wird für den Vergleich
als null behandelt. Die Modellantwort wird nicht verändert.
Nachweis: Alle 28 Regressionstests bestanden, einschließlich
acht neuer Fälle zum Berichtsabgleich. Kein zusätzlicher Modellaufruf.
Grenzen:
- Fehlende oder doppelte Befunde werden noch nicht erkannt.
- Einzelne Regelkennungen werden noch nicht mit Quellfehlern abgeglichen.
- Rejected-Berichte erhalten noch keinen entsprechenden Quellenabgleich.
- Freitextaussagen werden weiterhin manuell bewertet.
@@ -22,4 +22,66 @@ public static class InvoiceAnalysisValidator
return errors; return errors;
} }
public static IReadOnlyList<string> Validate(
InvoiceAnalysis analysis,
ImportReport report)
{
var errors = new List<string>(Validate(analysis));
// Der Zeilenabgleich gilt für abgeschlossene fachliche Prüfungen.
// Rejected-Berichte haben keine fachlichen Zeilenergebnisse.
if (report.Status != "Completed")
{
return errors;
}
for (var index = 0; index < analysis.Findings.Length; index++)
{
var finding = analysis.Findings[index];
var location = $"findings[{index}]";
if (finding.LineNumber is null)
{
errors.Add(
$"{location}: Bei Completed muss eine Zeilennummer angegeben sein.");
continue;
}
var sourceRow = report.Rows.FirstOrDefault(
row => row.LineNumber == finding.LineNumber.Value);
if (sourceRow is null)
{
errors.Add(
$"{location}: Zeile {finding.LineNumber} " +
"existiert nicht im Prüfbericht.");
continue;
}
if (sourceRow.IsValid)
{
errors.Add(
$"{location}: Zeile {sourceRow.LineNumber} " +
"hat die Prüfregeln bestanden und darf keinen Fehlerbefund erhalten.");
}
var expectedInvoiceId =
string.IsNullOrWhiteSpace(sourceRow.InvoiceId)
? null
: sourceRow.InvoiceId;
if (!string.Equals(
finding.InvoiceId,
expectedInvoiceId,
StringComparison.Ordinal))
{
errors.Add(
$"{location}: Die Rechnungsnummer stimmt nicht " +
"mit der angegebenen Zeile im Prüfbericht überein.");
}
}
return errors;
}
} }
@@ -0,0 +1,81 @@
using ImportAssist.Core;
using Xunit;
namespace ImportAssist.Core.Tests;
public sealed class InvoiceAnalysisReportValidationTests
{
[Theory]
[InlineData(3, "INV-002", true)]
[InlineData(99, "INV-002", false)]
[InlineData(null, "INV-002", false)]
[InlineData(3, "INV-999", false)]
[InlineData(2, "INV-001", false)]
[InlineData(3, null, false)]
public void Validate_ChecksFindingAgainstSourceReport(
int? lineNumber,
string? invoiceId,
bool expectedValid)
{
var analysis = CreateAnalysis(lineNumber, invoiceId);
var report = CreateReport("INV-002");
var errors = InvoiceAnalysisValidator.Validate(analysis, report);
if (expectedValid)
Assert.Empty(errors);
else
Assert.NotEmpty(errors);
}
[Theory]
[InlineData("")]
[InlineData(" \t")]
public void Validate_AcceptsNullForMissingSourceInvoiceId(
string sourceInvoiceId)
{
var analysis = CreateAnalysis(3, null);
var report = CreateReport(sourceInvoiceId);
var errors = InvoiceAnalysisValidator.Validate(analysis, report);
Assert.Empty(errors);
}
private static InvoiceAnalysis CreateAnalysis(
int? lineNumber,
string? invoiceId) =>
new(
Summary: "Testanalyse",
Findings:
[
new AnalysisFinding(
LineNumber: lineNumber,
InvoiceId: invoiceId,
RuleId: "INV-002",
Description: "Kundennummer fehlt.",
NextCheck: "Kundennummer anhand des Belegs prüfen.",
MissingInformation: ["Kundennummer"])
],
Limitations: []);
private static ImportReport CreateReport(string invoiceId) =>
new(
SourceBlob: "test.csv",
CheckedAtUtc: DateTimeOffset.UnixEpoch,
Status: "Completed",
Rows:
[
new InvoiceCheckResult(
LineNumber: 2,
InvoiceId: "INV-001",
IsValid: true,
Errors: []),
new InvoiceCheckResult(
LineNumber: 3,
InvoiceId: invoiceId,
IsValid: false,
Errors: ["Kundennummer fehlt."])
],
FileErrors: []);
}
+25 -2
View File
@@ -60,6 +60,28 @@ try
return 1; return 1;
} }
// Schema auf gültige JSON-Syntax prüfen.
using var schemaDocument = JsonDocument.Parse(schema);
// Den ursprünglichen Prüfbericht für den späteren Abgleich einlesen.
var reportOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
RespectRequiredConstructorParameters = true,
RespectNullableAnnotations = true
};
var sourceReport = JsonSerializer.Deserialize<ImportReport>(
report, reportOptions);
if (sourceReport is null ||
sourceReport.Rows.Any(row => row is null) ||
sourceReport.Status is not ("Completed" or "Rejected"))
{
Console.Error.WriteLine(
"Der Eingabebericht enthält keine unterstützte Berichtsstruktur.");
return 1;
}
var azureClient = new AzureOpenAIClient( var azureClient = new AzureOpenAIClient(
endpointUri, new ApiKeyCredential(apiKey)); endpointUri, new ApiKeyCredential(apiKey));
@@ -154,7 +176,8 @@ try
return 2; return 2;
} }
var validationErrors = InvoiceAnalysisValidator.Validate(analysis); var validationErrors =
InvoiceAnalysisValidator.Validate(analysis, sourceReport);
if (validationErrors.Count > 0) if (validationErrors.Count > 0)
{ {
@@ -182,7 +205,7 @@ catch (ClientResultException ex)
catch (JsonException ex) catch (JsonException ex)
{ {
Console.Error.WriteLine( Console.Error.WriteLine(
$"Die JSON-Antwort passt nicht zum erwarteten C#-Datenmodell: {ex.Message}"); $"JSON konnte nicht gelesen werden oder passt nicht zum Datenmodell: {ex.Message}");
return 2; return 2;
} }
catch (OperationCanceledException) catch (OperationCanceledException)