diff --git a/README.md b/README.md index 41d9b03..aaaffb6 100644 --- a/README.md +++ b/README.md @@ -143,4 +143,9 @@ Exitcodes: - 2: Modellantwort nicht regulär abgeschlossen. Die Antwort ist Freitext. Eine automatische fachliche Bewertung und -ein verbindliches JSON-Ausgabeformat sind noch nicht implementiert. \ No newline at end of file +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. diff --git a/docs/ai-evaluation-v1.md b/docs/ai-evaluation-v1.md index 65bfeca..35fbc3a 100644 --- a/docs/ai-evaluation-v1.md +++ b/docs/ai-evaluation-v1.md @@ -213,3 +213,25 @@ Bewertung: festgelegte Kriterien in diesem Lauf bestanden. - Ablehnungen, abgebrochene Antworten und ungültige Antwortstrukturen werden im Code behandelt, wurden aber noch nicht gezielt getestet. - 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. diff --git a/src/ImportAssist.Core/InvoiceAnalysisValidator.cs b/src/ImportAssist.Core/InvoiceAnalysisValidator.cs index 0e61d7c..a1ccf6d 100644 --- a/src/ImportAssist.Core/InvoiceAnalysisValidator.cs +++ b/src/ImportAssist.Core/InvoiceAnalysisValidator.cs @@ -22,4 +22,66 @@ public static class InvoiceAnalysisValidator return errors; } + + public static IReadOnlyList Validate( + InvoiceAnalysis analysis, + ImportReport report) + { + var errors = new List(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; + } } diff --git a/tests/ImportAssist.Core.Tests/InvoiceAnalysisReportValidationTests.cs b/tests/ImportAssist.Core.Tests/InvoiceAnalysisReportValidationTests.cs new file mode 100644 index 0000000..02de84f --- /dev/null +++ b/tests/ImportAssist.Core.Tests/InvoiceAnalysisReportValidationTests.cs @@ -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: []); +} diff --git a/tools/ImportAssist.Analyze/Program.cs b/tools/ImportAssist.Analyze/Program.cs index 188b57e..92ade2f 100644 --- a/tools/ImportAssist.Analyze/Program.cs +++ b/tools/ImportAssist.Analyze/Program.cs @@ -60,6 +60,28 @@ try 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( + 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( endpointUri, new ApiKeyCredential(apiKey)); @@ -154,7 +176,8 @@ try return 2; } - var validationErrors = InvoiceAnalysisValidator.Validate(analysis); + var validationErrors = + InvoiceAnalysisValidator.Validate(analysis, sourceReport); if (validationErrors.Count > 0) { @@ -182,7 +205,7 @@ catch (ClientResultException ex) catch (JsonException ex) { 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; } catch (OperationCanceledException)