Add structured AI analysis output and invoice ID validation

This commit is contained in:
admin committed 2026-09-11 14:33:31 +02:00
1 parent 12bc2a1822
commit 5b697ee200
8 files changed
+298 -16

No files matched your search

+27 -6
View File
@@ -25,11 +25,32 @@ Unterscheide Dateistrukturfehler von fachlichen Rechnungsfehlern.
Bei Rejected darfst du aus leeren rows und Zählern von null Bei Rejected darfst du aus leeren rows und Zählern von null
nicht auf eine fehlerfreie Datei schließen. nicht auf eine fehlerfreie Datei schließen.
Antworte auf Deutsch: Ausgabeformat:
1. Eine kurze Zusammenfassung.
2. Eine Tabelle mit den Spalten: Antworte ausschließlich als JSON-Objekt gemäß dem im API-Aufruf
Betroffene Zeile/Rechnung, Befund, Regelkennung, vorgegebenen Schema. Verwende keine Markdown-Codeblöcke.
nächster Prüfschritt, fehlende Information.
- summary: Kurze deutsche Zusammenfassung. Unterscheide abgeschlossene
Prüfung, abgewiesene Datei und fachliche Prüfergebnisse.
- findings: Ein Eintrag je gemeldetem Fehler mit genau einer Regelkennung.
Übernimm ausschließlich Fehler aus dem Prüfbericht.
- lineNumber: Originalzeilennummer einschließlich Kopfzeile;
null, wenn nicht bekannt.
- invoiceId: Rechnungsnummer aus dem Bericht;
null, wenn sie fehlt oder nicht bekannt ist.
- description: Belegter Fehlerbefund auf Deutsch.
- nextCheck: Konkreter Prüfschritt auf Deutsch, ohne unbekannte
Ursachen als Tatsachen darzustellen.
- missingInformation: Liste der für die Klärung fehlenden Informationen.
Verwende eine leere Liste, wenn keine fehlen.
- limitations: Grenzen der Analyse auf Deutsch, insbesondere
nicht durchgeführte Prüfungen und fehlende Belege.
Bei Rejected beschreiben die findings ausschließlich Dateifehler.
Erfinde keine fachlichen Rechnungsfehler.
Ohne gemeldete Fehler ist findings eine leere Liste.
Die Analyse erteilt keine Importfreigabe.
Wenn eine Erklärung nicht durch die Quellen gedeckt ist, Wenn eine Erklärung nicht durch die Quellen gedeckt ist,
benenne diese Grenze ausdrücklich. benenne diese Grenze ausdrücklich.
@@ -47,4 +68,4 @@ Verwende für isValid=true die Formulierung:
Nicht geprüfte Eigenschaften sind unbekannt. Nicht geprüfte Eigenschaften sind unbekannt.
Insbesondere erlaubt die fehlende Währungsprüfung keine Aussage Insbesondere erlaubt die fehlende Währungsprüfung keine Aussage
darüber, ob die Währung korrekt oder fehlerhaft ist. darüber, ob die Währung korrekt oder fehlerhaft ist.
+69
View File
@@ -0,0 +1,69 @@
{
"type": "object",
"properties": {
"summary": {
"type": "string"
},
"findings": {
"type": "array",
"items": {
"type": "object",
"properties": {
"lineNumber": {
"type": ["integer", "null"],
"description": "Zeilennummer aus dem Prüfbericht, einschließlich Kopfzeile; null, falls unbekannt."
},
"invoiceId": {
"type": ["string", "null"],
"description": "Rechnungsnummer aus dem Prüfbericht; null, falls sie fehlt oder nicht bekannt ist."
},
"ruleId": {
"type": "string",
"enum": [
"CSV-001",
"INV-001",
"INV-002",
"INV-003",
"INV-004"
]
},
"description": {
"type": "string",
"description": "Durch den Prüfbericht belegter Fehlerbefund."
},
"nextCheck": {
"type": "string",
"description": "Nächster Prüfschritt, ohne unbekannte Ursachen als Tatsachen darzustellen."
},
"missingInformation": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"lineNumber",
"invoiceId",
"ruleId",
"description",
"nextCheck",
"missingInformation"
],
"additionalProperties": false
}
},
"limitations": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"summary",
"findings",
"limitations"
],
"additionalProperties": false
}
+39 -1
View File
@@ -174,4 +174,42 @@ Bewertung: für diesen Testlauf bestanden.
### Grenzen ### Grenzen
Je ein manueller C#-Testlauf pro Fall. Je ein manueller C#-Testlauf pro Fall.
Keine Aussage über zuverlässige Wiederholbarkeit oder andere Eingaben. Keine Aussage über zuverlässige Wiederholbarkeit oder andere Eingaben.
Die Antworten werden bislang als Freitext ausgegeben und manuell geprüft. Die Antworten werden bislang als Freitext ausgegeben und manuell geprüft.
## Strukturierte C#-Testläufe vom 11.09.2026
### Konfiguration
- Modellbereitstellung: importassist-analysis
- Antwortschema: docs/ai-analysis.schema.json
- Structured Outputs mit aktiviertem strikten Modus
- Deserialisierung in InvoiceAnalysis und AnalysisFinding
- Maximale Ausgabetokens: 2000
### Completed-Bericht
- JSON erfolgreich in C# eingelesen.
- Sechs Befunde mit korrekten Zeilen und Regelkennungen.
- limitations korrekt als Liste auf oberster Ebene.
- Fehlende Rechnungsnummer in Zeile 6 als leerer String statt null.
Das Schema erlaubt diesen String; die semantische Vorgabe wurde verletzt.
- Empfehlung zur Änderung auf einen positiven Betrag zu pauschal.
Der fachlich richtige Betrag muss zuerst anhand der Quelle geklärt werden.
- Tokenverbrauch: 1894 Eingabe, 874 Ausgabe.
Bewertung: Struktur bestanden, fachliche Prüfung teilweise bestanden.
### Rejected-Bericht
- JSON erfolgreich in C# eingelesen.
- Genau ein Befund: CSV-001, Zeile 3, invoiceId null.
- Keine konkrete Fehlerursache erfunden.
- Fehlende Originalzeile und ausgebliebene fachliche Prüfung benannt.
- Tokenverbrauch: 1662 Eingabe, 236 Ausgabe.
Bewertung: festgelegte Kriterien in diesem Lauf bestanden.
### Offene Punkte
- Leere oder ausschließlich aus Leerzeichen bestehende invoiceId
zusätzlich in C# zurückweisen; bei fehlender Nummer ist null vorgesehen.
- Korrekturvorschläge müssen den fachlich richtigen Quellwert respektieren.
- 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.
+14
View File
@@ -0,0 +1,14 @@
namespace ImportAssist.Core;
public sealed record InvoiceAnalysis(
string Summary,
AnalysisFinding[] Findings,
string[] Limitations);
public sealed record AnalysisFinding(
int? LineNumber,
string? InvoiceId,
string RuleId,
string Description,
string NextCheck,
string[] MissingInformation);
@@ -0,0 +1,25 @@
namespace ImportAssist.Core;
public static class InvoiceAnalysisValidator
{
public static IReadOnlyList<string> Validate(InvoiceAnalysis analysis)
{
var errors = new List<string>();
for (var index = 0; index < analysis.Findings.Length; index++)
{
var invoiceId = analysis.Findings[index].InvoiceId;
if (invoiceId is not null &&
string.IsNullOrWhiteSpace(invoiceId))
{
errors.Add(
$"findings[{index}].invoiceId: " +
"Eine fehlende Rechnungsnummer muss null sein; " +
"leere Werte oder reine Leerzeichen sind unzulässig.");
}
}
return errors;
}
}
@@ -0,0 +1,40 @@
using ImportAssist.Core;
using Xunit;
namespace ImportAssist.Core.Tests;
public sealed class InvoiceAnalysisValidatorTests
{
[Theory]
[InlineData(null, true)]
[InlineData("INV-001", true)]
[InlineData("", false)]
[InlineData(" ", false)]
[InlineData("\t", false)]
[InlineData("\r\n", false)]
public void Validate_ChecksInvoiceIdContract(
string? invoiceId,
bool expectedValid)
{
var analysis = new InvoiceAnalysis(
Summary: "Testanalyse",
Findings:
[
new AnalysisFinding(
LineNumber: 6,
InvoiceId: invoiceId,
RuleId: "INV-004",
Description: "Betrag muss größer als null sein.",
NextCheck: "Originalbetrag anhand des Belegs prüfen.",
MissingInformation: ["Originalbetrag"])
],
Limitations: []);
var errors = InvoiceAnalysisValidator.Validate(analysis);
if (expectedValid)
Assert.Empty(errors);
else
Assert.Single(errors);
}
}
@@ -13,4 +13,8 @@
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.12" /> <PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.12" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ImportAssist.Core\ImportAssist.Core.csproj" />
</ItemGroup>
</Project> </Project>
+80 -9
View File
@@ -2,6 +2,9 @@
using Azure.AI.OpenAI; using Azure.AI.OpenAI;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using OpenAI.Chat; using OpenAI.Chat;
using System.Text.Json;
using System.Text.Json.Serialization;
using ImportAssist.Core;
if (args.Length != 1) if (args.Length != 1)
{ {
@@ -38,6 +41,9 @@ if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var endpointUri) ||
try try
{ {
var schema = await File.ReadAllTextAsync(
"docs/ai-analysis.schema.json");
var instructions = await File.ReadAllTextAsync( var instructions = await File.ReadAllTextAsync(
"docs/ai-analysis-prompt.md"); "docs/ai-analysis-prompt.md");
@@ -78,7 +84,11 @@ try
var options = new ChatCompletionOptions var options = new ChatCompletionOptions
{ {
MaxOutputTokenCount = 2000 MaxOutputTokenCount = 2000,
ResponseFormat = ChatResponseFormat.CreateJsonSchemaFormat(
jsonSchemaFormatName: "invoice_analysis",
jsonSchema: BinaryData.FromString(schema),
jsonSchemaIsStrict: true)
}; };
Console.Error.WriteLine($"Analyse mit {deployment} läuft ..."); Console.Error.WriteLine($"Analyse mit {deployment} läuft ...");
@@ -88,17 +98,17 @@ try
ChatCompletion completion = await chatClient.CompleteChatAsync( ChatCompletion completion = await chatClient.CompleteChatAsync(
messages, options, timeout.Token); messages, options, timeout.Token);
foreach (var part in completion.Content)
{
Console.Write(part.Text);
}
Console.WriteLine();
Console.Error.WriteLine( Console.Error.WriteLine(
$"Tokens: Eingabe {completion.Usage.InputTokenCount}, " + $"Tokens: Eingabe {completion.Usage.InputTokenCount}, " +
$"Ausgabe {completion.Usage.OutputTokenCount}"); $"Ausgabe {completion.Usage.OutputTokenCount}");
if (!string.IsNullOrWhiteSpace(completion.Refusal))
{
Console.Error.WriteLine(
$"Das Modell hat die Analyse abgelehnt: {completion.Refusal}");
return 2;
}
if (completion.FinishReason != ChatFinishReason.Stop) if (completion.FinishReason != ChatFinishReason.Stop)
{ {
Console.Error.WriteLine( Console.Error.WriteLine(
@@ -106,6 +116,61 @@ try
return 2; return 2;
} }
var responseJson = string.Concat(
completion.Content.Select(part => part.Text));
if (string.IsNullOrWhiteSpace(responseJson))
{
Console.Error.WriteLine("Das Modell hat keinen Antwortinhalt geliefert.");
return 2;
}
var jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = false,
RespectRequiredConstructorParameters = true,
RespectNullableAnnotations = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
WriteIndented = true
};
var analysis = JsonSerializer.Deserialize<InvoiceAnalysis>(
responseJson, jsonOptions);
if (analysis is null)
{
Console.Error.WriteLine("Die Antwort enthält kein Analyseobjekt.");
return 2;
}
// Nullwerte innerhalb von Arrays werden durch die
// Nullable-Einstellung des Serializers nicht vollständig geprüft.
if (analysis.Findings.Any(finding =>
finding is null ||
finding.MissingInformation.Any(item => item is null)) ||
analysis.Limitations.Any(item => item is null))
{
Console.Error.WriteLine("Die Antwort enthält unzulässige Null-Einträge.");
return 2;
}
var validationErrors = InvoiceAnalysisValidator.Validate(analysis);
if (validationErrors.Count > 0)
{
Console.Error.WriteLine(
"Die Modellantwort verletzt den Analysevertrag:");
foreach (var error in validationErrors)
{
Console.Error.WriteLine(error);
}
return 2;
}
Console.WriteLine(JsonSerializer.Serialize(analysis, jsonOptions));
return 0; return 0;
} }
catch (ClientResultException ex) catch (ClientResultException ex)
@@ -114,6 +179,12 @@ catch (ClientResultException ex)
$"Azure-Modellaufruf fehlgeschlagen. HTTP-Status: {ex.Status}"); $"Azure-Modellaufruf fehlgeschlagen. HTTP-Status: {ex.Status}");
return 1; return 1;
} }
catch (JsonException ex)
{
Console.Error.WriteLine(
$"Die JSON-Antwort passt nicht zum erwarteten C#-Datenmodell: {ex.Message}");
return 2;
}
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
Console.Error.WriteLine("Die Analyse hat das Zeitlimit überschritten."); Console.Error.WriteLine("Die Analyse hat das Zeitlimit überschritten.");
@@ -128,4 +199,4 @@ catch (UnauthorizedAccessException)
{ {
Console.Error.WriteLine("Keine Leseberechtigung für eine Eingabedatei."); Console.Error.WriteLine("Keine Leseberechtigung für eine Eingabedatei.");
return 1; return 1;
} }