Add structured AI analysis output and invoice ID validation
This commit is contained in:
1 parent
12bc2a1822
commit
5b697ee200
8 files changed
+295
-13
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
|
||||
nicht auf eine fehlerfreie Datei schließen.
|
||||
|
||||
Antworte auf Deutsch:
|
||||
1. Eine kurze Zusammenfassung.
|
||||
2. Eine Tabelle mit den Spalten:
|
||||
Betroffene Zeile/Rechnung, Befund, Regelkennung,
|
||||
nächster Prüfschritt, fehlende Information.
|
||||
Ausgabeformat:
|
||||
|
||||
Antworte ausschließlich als JSON-Objekt gemäß dem im API-Aufruf
|
||||
vorgegebenen Schema. Verwende keine Markdown-Codeblöcke.
|
||||
|
||||
- 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,
|
||||
benenne diese Grenze ausdrücklich.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -175,3 +175,41 @@ Bewertung: für diesen Testlauf bestanden.
|
||||
Je ein manueller C#-Testlauf pro Fall.
|
||||
Keine Aussage über zuverlässige Wiederholbarkeit oder andere Eingaben.
|
||||
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" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ImportAssist.Core\ImportAssist.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -2,6 +2,9 @@
|
||||
using Azure.AI.OpenAI;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using OpenAI.Chat;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using ImportAssist.Core;
|
||||
|
||||
if (args.Length != 1)
|
||||
{
|
||||
@@ -38,6 +41,9 @@ if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var endpointUri) ||
|
||||
|
||||
try
|
||||
{
|
||||
var schema = await File.ReadAllTextAsync(
|
||||
"docs/ai-analysis.schema.json");
|
||||
|
||||
var instructions = await File.ReadAllTextAsync(
|
||||
"docs/ai-analysis-prompt.md");
|
||||
|
||||
@@ -78,7 +84,11 @@ try
|
||||
|
||||
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 ...");
|
||||
@@ -88,17 +98,17 @@ try
|
||||
ChatCompletion completion = await chatClient.CompleteChatAsync(
|
||||
messages, options, timeout.Token);
|
||||
|
||||
foreach (var part in completion.Content)
|
||||
{
|
||||
Console.Write(part.Text);
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
|
||||
Console.Error.WriteLine(
|
||||
$"Tokens: Eingabe {completion.Usage.InputTokenCount}, " +
|
||||
$"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)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
@@ -106,6 +116,61 @@ try
|
||||
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;
|
||||
}
|
||||
catch (ClientResultException ex)
|
||||
@@ -114,6 +179,12 @@ catch (ClientResultException ex)
|
||||
$"Azure-Modellaufruf fehlgeschlagen. HTTP-Status: {ex.Status}");
|
||||
return 1;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"Die JSON-Antwort passt nicht zum erwarteten C#-Datenmodell: {ex.Message}");
|
||||
return 2;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Console.Error.WriteLine("Die Analyse hat das Zeitlimit überschritten.");
|
||||
|
||||
Reference in new issue
Block a user