diff --git a/docs/ai-analysis-prompt.md b/docs/ai-analysis-prompt.md index 421499f..fdc2d5e 100644 --- a/docs/ai-analysis-prompt.md +++ b/docs/ai-analysis-prompt.md @@ -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. @@ -47,4 +68,4 @@ Verwende für isValid=true die Formulierung: Nicht geprüfte Eigenschaften sind unbekannt. Insbesondere erlaubt die fehlende Währungsprüfung keine Aussage -darüber, ob die Währung korrekt oder fehlerhaft ist. \ No newline at end of file +darüber, ob die Währung korrekt oder fehlerhaft ist. diff --git a/docs/ai-analysis.schema.json b/docs/ai-analysis.schema.json new file mode 100644 index 0000000..e0f8743 --- /dev/null +++ b/docs/ai-analysis.schema.json @@ -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 +} diff --git a/docs/ai-evaluation-v1.md b/docs/ai-evaluation-v1.md index 491e9ac..65bfeca 100644 --- a/docs/ai-evaluation-v1.md +++ b/docs/ai-evaluation-v1.md @@ -174,4 +174,42 @@ Bewertung: für diesen Testlauf bestanden. ### Grenzen 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. \ No newline at end of file +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. diff --git a/src/ImportAssist.Core/InvoiceAnalysis.cs b/src/ImportAssist.Core/InvoiceAnalysis.cs new file mode 100644 index 0000000..c402882 --- /dev/null +++ b/src/ImportAssist.Core/InvoiceAnalysis.cs @@ -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); diff --git a/src/ImportAssist.Core/InvoiceAnalysisValidator.cs b/src/ImportAssist.Core/InvoiceAnalysisValidator.cs new file mode 100644 index 0000000..0e61d7c --- /dev/null +++ b/src/ImportAssist.Core/InvoiceAnalysisValidator.cs @@ -0,0 +1,25 @@ +namespace ImportAssist.Core; + +public static class InvoiceAnalysisValidator +{ + public static IReadOnlyList Validate(InvoiceAnalysis analysis) + { + var errors = new List(); + + 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; + } +} diff --git a/tests/ImportAssist.Core.Tests/InvoiceAnalysisValidatorTests.cs b/tests/ImportAssist.Core.Tests/InvoiceAnalysisValidatorTests.cs new file mode 100644 index 0000000..ddbd17d --- /dev/null +++ b/tests/ImportAssist.Core.Tests/InvoiceAnalysisValidatorTests.cs @@ -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); + } +} diff --git a/tools/ImportAssist.Analyze/ImportAssist.Analyze.csproj b/tools/ImportAssist.Analyze/ImportAssist.Analyze.csproj index 364882f..f80af9a 100644 --- a/tools/ImportAssist.Analyze/ImportAssist.Analyze.csproj +++ b/tools/ImportAssist.Analyze/ImportAssist.Analyze.csproj @@ -13,4 +13,8 @@ + + + + diff --git a/tools/ImportAssist.Analyze/Program.cs b/tools/ImportAssist.Analyze/Program.cs index fdece0e..188b57e 100644 --- a/tools/ImportAssist.Analyze/Program.cs +++ b/tools/ImportAssist.Analyze/Program.cs @@ -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( + 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."); @@ -128,4 +199,4 @@ catch (UnauthorizedAccessException) { Console.Error.WriteLine("Keine Leseberechtigung für eine Eingabedatei."); return 1; -} \ No newline at end of file +}