Add structured AI analysis output and invoice ID validation
This commit is contained in:
1 parent
12bc2a1822
commit
5b697ee200
8 files changed
+298
-16
No files matched your search
@@ -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.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user