Validate AI findings against source invoice reports
This commit is contained in:
1 parent
5b697ee200
commit
6e1187cdd1
5 files changed
+195
-2
No files matched your search
@@ -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.
|
||||||
@@ -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: []);
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in new issue
Block a user