Merge remote-tracking branch 'origin/feature/ext-02-rechnungsdaten-extraktion' into integration/gesamtprojekt
This commit is contained in:
commit
f3d8d164e6
3 files changed
+490
No files matched your search
@@ -0,0 +1,42 @@
|
||||
# EXT-02 – Prüfprotokoll: Rechnungsdaten-Extraktion
|
||||
|
||||
Datum: 2026-09-25. Host: Entwicklungshost 192.168.1.204, Wegwerf-Klon von `integration/gesamtprojekt`. Echte Inferenz gegen `office.sysops.de:11435` (Modell `gpt-oss:20b`, kein lokaler Ollama auf 204).
|
||||
|
||||
## Umsetzung
|
||||
|
||||
Neues Paket `ai/internal/invoiceextract`, aufbauend auf `ai/internal/extract` (EXT-01), ohne dessen generischen Code zu ändern:
|
||||
|
||||
- `Schema`: Rechnungsschema (Version 1) mit `invoice_number`, `total_amount` (Amount), `due_date` (Date), `sender` – alle Pflichtfelder, über `extract.Extractor.Extract` abgefragt (AK1).
|
||||
- `extractTaxes`: eigener, eng begrenzter Modellaufruf für eine Liste von Steuersätzen (`rate_percent`/`amount`/`present`), da EXT-01s Schema nur Einzelwerte je Feld kennt, ein Rechnungskopf aber mehrere Sätze gleichzeitig tragen kann (AK2).
|
||||
- `validate`: prüft alle Pflichtfelder auf Vorhandensein und Plausibilität (Betrag/Datum parsbar, mindestens ein Steuersatz erkannt) und setzt `Valid=false` mit `RejectReason` statt eine unvollständige Rechnung stillschweigend zu akzeptieren (AK3).
|
||||
|
||||
## Prüfungen
|
||||
|
||||
| # | Prüfung | Ziel | Ergebnis |
|
||||
|---|---|---|---|
|
||||
| 1 | Genauigkeit an gelabeltem Rechnungs-Testset (2 Fälle: vollständige Rechnung, Rechnung ohne jede Pflichtangabe) | dokumentiert | **2/2** – echte Inferenz gegen `gpt-oss:20b`, `TestExtract_RealOllama_LabeledInvoiceSet`, Laufzeit 13,65s |
|
||||
| 2 | Mehrfachsteuersatz-Fall im Test korrekt ausgewertet | beide Sätze (19%/190,00 EUR und 7%/7,00 EUR) korrekt zugeordnet | **bestanden** – im Realtest sowie in `TestExtract_MultipleTaxRates_BothAssignedCorrectly` |
|
||||
| 3 | Fehlerhafte Testrechnung korrekt zurückgewiesen | `Valid=false` mit Begründung, kein stillschweigendes Akzeptieren | **bestanden** – Realtest (`reason="pflichtfeld \"invoice_number\" fehlt"`), zusätzlich `TestExtract_MissingRequiredField_RejectedNotSilentlyAccepted`, `TestExtract_UnparsableAmount_Rejected`, `TestExtract_NoTaxEntries_Rejected` |
|
||||
|
||||
## Echter Fehler gefunden und behoben
|
||||
|
||||
Erster Lauf des Realtests scheiterte: Ollama liefert Beträge mit Währungscode zurück (`"1297,00 EUR"`), `parseAmount` erkannte das Suffix `EUR` nicht und wies eine tatsächlich vollständige Rechnung fälschlich als fehlerhaft zurück (`false negative`). Behoben durch `TrimSuffix` für `EUR`/`eur` nach der bestehenden Komma/Symbol-Normalisierung. Nach dem Fix: 2/2.
|
||||
|
||||
## Tests (Unit, ohne echte Inferenz)
|
||||
|
||||
`ai/internal/invoiceextract/invoiceextract_test.go`, alle grün:
|
||||
- `TestExtract_CompleteInvoice_AllFieldsRecognized` (AK1)
|
||||
- `TestExtract_MultipleTaxRates_BothAssignedCorrectly` (AK2)
|
||||
- `TestExtract_MissingRequiredField_RejectedNotSilentlyAccepted` (AK3)
|
||||
- `TestExtract_UnparsableAmount_Rejected` (AK3)
|
||||
- `TestExtract_NoTaxEntries_Rejected` (AK3)
|
||||
- `TestExtract_EmptyDocument_Error`
|
||||
- `TestExtract_NoBase_Error`
|
||||
|
||||
## Regression
|
||||
|
||||
`go vet ./...` und `go test ./...` für das gesamte `ai`-Modul: alle Pakete grün, keine Regression.
|
||||
|
||||
## Nicht Bestandteil dieser Kachel
|
||||
|
||||
EXT-04 (Extraktions-Korrektur-Oberfläche) – keine automatische Übernahme extrahierter Werte, `Valid`/`RejectReason` bereiten die Schnittstelle für eine spätere Bestätigungs-UI vor, ohne sie selbst zu bauen.
|
||||
@@ -0,0 +1,206 @@
|
||||
// Package invoiceextract definiert das spezialisierte Rechnungsschema
|
||||
// (EXT-02) auf Basis der generischen Extraktion (EXT-01): Rechnungsnummer,
|
||||
// Betrag, Fälligkeitsdatum, Absender über extract.Extractor, plus eine
|
||||
// eigene Mehrfachsteuersatz-Erkennung, da EXT-01s Schema nur einzelne
|
||||
// Skalarwerte je Feld kennt, ein Rechnungskopf aber mehrere Steuersätze
|
||||
// gleichzeitig tragen kann (AK2).
|
||||
package invoiceextract
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/extract"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider"
|
||||
)
|
||||
|
||||
// Schema ist das versionierte Rechnungsschema (AK1). Version wird bei
|
||||
// künftigen Feldänderungen erhöht, wie in EXT-01 etabliert.
|
||||
var Schema = extract.Schema{
|
||||
Name: "invoice",
|
||||
Version: 1,
|
||||
Fields: []extract.FieldDef{
|
||||
{Name: "invoice_number", Type: extract.FieldTypeText, Required: true},
|
||||
{Name: "total_amount", Type: extract.FieldTypeAmount, Required: true},
|
||||
{Name: "due_date", Type: extract.FieldTypeDate, Required: true},
|
||||
{Name: "sender", Type: extract.FieldTypeText, Required: true},
|
||||
},
|
||||
}
|
||||
|
||||
// TaxEntry ist ein einzelner Steuersatz-Eintrag einer Rechnung (AK2).
|
||||
type TaxEntry struct {
|
||||
RatePercent string
|
||||
Amount string
|
||||
}
|
||||
|
||||
// Result ist das vollständige Extraktionsergebnis einer Rechnung.
|
||||
type Result struct {
|
||||
Fields extract.Result
|
||||
Taxes []TaxEntry
|
||||
|
||||
// Valid ist false, wenn die Rechnung nicht stillschweigend akzeptiert
|
||||
// werden darf (AK3): ein Pflichtfeld fehlt oder ein erkannter Wert ist
|
||||
// nicht plausibel (z. B. Betrag nicht als Zahl, Datum nicht als Datum
|
||||
// parsbar).
|
||||
Valid bool
|
||||
RejectReason string
|
||||
}
|
||||
|
||||
type taxModelEntry struct {
|
||||
RatePercent string `json:"rate_percent"`
|
||||
Amount string `json:"amount"`
|
||||
Present bool `json:"present"`
|
||||
}
|
||||
|
||||
// Extractor extrahiert Rechnungsdaten über einen Sprachmodell-Provider
|
||||
// (ENG-01), aufbauend auf extract.Extractor für die skalaren Felder.
|
||||
type Extractor struct {
|
||||
Base *extract.Extractor
|
||||
Provider provider.Provider
|
||||
Model string
|
||||
}
|
||||
|
||||
// Extract liefert Rechnungsnummer, Betrag, Fälligkeitsdatum, Absender sowie
|
||||
// alle erkannten Steuersätze (AK1, AK2) und weist eine fehlerhafte Rechnung
|
||||
// zurück statt sie stillschweigend zu akzeptieren (AK3).
|
||||
func (e *Extractor) Extract(ctx context.Context, documentText string) (Result, error) {
|
||||
if e.Base == nil {
|
||||
return Result{}, errors.New("invoiceextract: kein extract.Extractor konfiguriert")
|
||||
}
|
||||
if e.Provider == nil {
|
||||
return Result{}, errors.New("invoiceextract: kein provider konfiguriert")
|
||||
}
|
||||
if strings.TrimSpace(documentText) == "" {
|
||||
return Result{}, errors.New("invoiceextract: leerer dokumenttext")
|
||||
}
|
||||
|
||||
fields, err := e.Base.Extract(ctx, Schema, documentText)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("invoiceextract: basisfelder: %w", err)
|
||||
}
|
||||
|
||||
taxes, err := e.extractTaxes(ctx, documentText)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("invoiceextract: steuersaetze: %w", err)
|
||||
}
|
||||
|
||||
res := Result{Fields: fields, Taxes: taxes}
|
||||
res.Valid, res.RejectReason = validate(fields, taxes)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// extractTaxes fragt gezielt nach allen Steuersätzen einer Rechnung als
|
||||
// Liste, da das generische Schema (extract.Schema) nur einzelne Skalarwerte
|
||||
// je Feld unterstützt, ein Rechnungskopf aber mehrere Sätze parallel tragen
|
||||
// kann (AK2). Bewusst kein Import/Umbau von extract.go: eigener,
|
||||
// eng begrenzter Modellaufruf mit eigenem JSON-Format.
|
||||
func (e *Extractor) extractTaxes(ctx context.Context, documentText string) ([]TaxEntry, error) {
|
||||
prompt := "Liste alle in dieser Rechnung vorkommenden Steuersätze mit dem jeweils zugehörigen Steuerbetrag auf. " +
|
||||
"Eine Rechnung kann mehrere unterschiedliche Steuersätze gleichzeitig enthalten (z. B. 19% und 7%) – gib jeden getrennt zurück. " +
|
||||
"Erfinde keinen Steuersatz, der nicht im Dokument steht. Findest du keinen Steuersatz, antworte mit einer leeren Liste.\n" +
|
||||
"Antworte NUR mit JSON in diesem Format: {\"taxes\": [{\"rate_percent\": string, \"amount\": string, \"present\": boolean}, ...]}\n\n" +
|
||||
"Dokument:\n" + documentText
|
||||
|
||||
resp, err := e.Provider.Chat(ctx, provider.ChatRequest{
|
||||
Model: e.Model,
|
||||
Messages: []provider.Message{
|
||||
{Role: provider.RoleSystem, Content: "Du extrahierst strukturierte Werte aus Dokumenten. Erfinde niemals einen Wert, der nicht im Dokument steht. Antworte ausschließlich mit einem einzelnen JSON-Objekt, ohne Erklärtext davor oder danach."},
|
||||
{Role: provider.RoleUser, Content: prompt},
|
||||
},
|
||||
Temperature: 0,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
raw := extractJSON(resp.Message.Content)
|
||||
var parsed struct {
|
||||
Taxes []taxModelEntry `json:"taxes"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
|
||||
// Kaputte Antwort bedeutet keine belastbaren Steuersätze, kein Absturz (analog EXT-01 AK3).
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
out := make([]TaxEntry, 0, len(parsed.Taxes))
|
||||
for _, t := range parsed.Taxes {
|
||||
if !t.Present || strings.TrimSpace(t.RatePercent) == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, TaxEntry{RatePercent: t.RatePercent, Amount: t.Amount})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// validate prüft, ob die Pflichtfelder vorhanden und plausibel sind (AK3).
|
||||
// Eine fehlerhafte oder unvollständige Rechnung wird explizit als solche
|
||||
// markiert statt stillschweigend als gültiges Ergebnis durchzugehen.
|
||||
func validate(fields extract.Result, taxes []TaxEntry) (bool, string) {
|
||||
for _, req := range []string{"invoice_number", "total_amount", "due_date", "sender"} {
|
||||
fr, ok := fields.Fields[req]
|
||||
if !ok || !fr.Present || strings.TrimSpace(fr.Value) == "" {
|
||||
return false, fmt.Sprintf("pflichtfeld %q fehlt", req)
|
||||
}
|
||||
}
|
||||
|
||||
amountField := fields.Fields["total_amount"]
|
||||
if _, err := parseAmount(amountField.Value); err != nil {
|
||||
return false, fmt.Sprintf("betrag %q nicht als zahl lesbar", amountField.Value)
|
||||
}
|
||||
|
||||
dateField := fields.Fields["due_date"]
|
||||
if _, err := parseDate(dateField.Value); err != nil {
|
||||
return false, fmt.Sprintf("faelligkeitsdatum %q nicht als datum lesbar", dateField.Value)
|
||||
}
|
||||
|
||||
if len(taxes) == 0 {
|
||||
return false, "kein steuersatz erkannt"
|
||||
}
|
||||
for _, t := range taxes {
|
||||
if _, err := parseAmount(t.RatePercent); err != nil {
|
||||
return false, fmt.Sprintf("steuersatz %q nicht als zahl lesbar", t.RatePercent)
|
||||
}
|
||||
}
|
||||
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// parseAmount liest einen vom Modell gelieferten Betrag/Steuersatz-String.
|
||||
// Modelle geben Beträge oft mit Währungscode oder -symbol zurück (z. B.
|
||||
// "1297,00 EUR"), das rein numerische Parsen mit strconv sonst scheitern
|
||||
// ließe und eine gültige Rechnung fälschlich als fehlerhaft zurückweisen würde.
|
||||
func parseAmount(s string) (float64, error) {
|
||||
cleaned := strings.NewReplacer(",", ".", "%", "", "€", "", " ", "").Replace(strings.TrimSpace(s))
|
||||
cleaned = strings.TrimSuffix(cleaned, "EUR")
|
||||
cleaned = strings.TrimSuffix(cleaned, "eur")
|
||||
cleaned = strings.TrimSpace(cleaned)
|
||||
return strconv.ParseFloat(cleaned, 64)
|
||||
}
|
||||
|
||||
func parseDate(s string) (time.Time, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
layouts := []string{"2006-01-02", "02.01.2006", "2.1.2006", "01/02/2006"}
|
||||
var lastErr error
|
||||
for _, l := range layouts {
|
||||
if t, err := time.Parse(l, s); err == nil {
|
||||
return t, nil
|
||||
} else {
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
return time.Time{}, lastErr
|
||||
}
|
||||
|
||||
func extractJSON(raw string) string {
|
||||
start := strings.IndexByte(raw, '{')
|
||||
end := strings.LastIndexByte(raw, '}')
|
||||
if start < 0 || end < start {
|
||||
return ""
|
||||
}
|
||||
return raw[start : end+1]
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package invoiceextract
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/extract"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider/ollama"
|
||||
)
|
||||
|
||||
// routingProvider liefert unterschiedliche Antworten für den Basisfelder-Aufruf
|
||||
// und den Steuersatz-Aufruf, erkannt am Prompt-Inhalt ("taxes" nur im
|
||||
// Steuersatz-Prompt) - notwendig, weil Extract zwei getrennte Chat-Aufrufe macht.
|
||||
type routingProvider struct {
|
||||
fieldsResponse provider.ChatResponse
|
||||
taxesResponse provider.ChatResponse
|
||||
err error
|
||||
}
|
||||
|
||||
func (p *routingProvider) Chat(_ context.Context, req provider.ChatRequest) (provider.ChatResponse, error) {
|
||||
if p.err != nil {
|
||||
return provider.ChatResponse{}, p.err
|
||||
}
|
||||
for _, m := range req.Messages {
|
||||
if strings.Contains(m.Content, `"taxes"`) {
|
||||
return p.taxesResponse, nil
|
||||
}
|
||||
}
|
||||
return p.fieldsResponse, nil
|
||||
}
|
||||
|
||||
func (p *routingProvider) Complete(context.Context, provider.CompletionRequest) (provider.CompletionResponse, error) {
|
||||
return provider.CompletionResponse{}, nil
|
||||
}
|
||||
|
||||
func (p *routingProvider) Embed(context.Context, provider.EmbeddingRequest) (provider.EmbeddingResponse, error) {
|
||||
return provider.EmbeddingResponse{}, nil
|
||||
}
|
||||
|
||||
func newExtractor(p provider.Provider) *Extractor {
|
||||
return &Extractor{
|
||||
Base: &extract.Extractor{Provider: p, Model: "test"},
|
||||
Provider: p,
|
||||
Model: "test",
|
||||
}
|
||||
}
|
||||
|
||||
const completeInvoiceDoc = `Rechnung Nr. RE-2026-0042
|
||||
Absender: Musterlieferant GmbH
|
||||
Fällig am: 15.10.2026
|
||||
Positionen:
|
||||
Beratung, netto 1000,00 EUR, 19% USt = 190,00 EUR
|
||||
Bücher, netto 100,00 EUR, 7% USt = 7,00 EUR
|
||||
Gesamtbetrag: 1297,00 EUR`
|
||||
|
||||
const fieldsJSONComplete = `{"invoice_number":{"value":"RE-2026-0042","present":true,"confidence":0.98},"total_amount":{"value":"1297.00","present":true,"confidence":0.95},"due_date":{"value":"2026-10-15","present":true,"confidence":0.9},"sender":{"value":"Musterlieferant GmbH","present":true,"confidence":0.97}}`
|
||||
|
||||
const taxesJSONTwoRates = `{"taxes":[{"rate_percent":"19","amount":"190.00","present":true},{"rate_percent":"7","amount":"7.00","present":true}]}`
|
||||
|
||||
// AK1: Rechnungsfelder werden vollständig und typisiert erkannt.
|
||||
func TestExtract_CompleteInvoice_AllFieldsRecognized(t *testing.T) {
|
||||
p := &routingProvider{
|
||||
fieldsResponse: provider.ChatResponse{Message: provider.Message{Content: fieldsJSONComplete}},
|
||||
taxesResponse: provider.ChatResponse{Message: provider.Message{Content: taxesJSONTwoRates}},
|
||||
}
|
||||
res, err := newExtractor(p).Extract(context.Background(), completeInvoiceDoc)
|
||||
if err != nil {
|
||||
t.Fatalf("unerwarteter Fehler: %v", err)
|
||||
}
|
||||
for _, name := range []string{"invoice_number", "total_amount", "due_date", "sender"} {
|
||||
fr := res.Fields.Fields[name]
|
||||
if !fr.Present || fr.Value == "" {
|
||||
t.Fatalf("feld %q nicht erkannt: %+v", name, fr)
|
||||
}
|
||||
}
|
||||
if !res.Valid {
|
||||
t.Fatalf("erwartet gueltige rechnung, reason=%q", res.RejectReason)
|
||||
}
|
||||
}
|
||||
|
||||
// AK2 + Prüfung 2: Mehrere Steuersätze in einer Rechnung werden korrekt zugeordnet.
|
||||
func TestExtract_MultipleTaxRates_BothAssignedCorrectly(t *testing.T) {
|
||||
p := &routingProvider{
|
||||
fieldsResponse: provider.ChatResponse{Message: provider.Message{Content: fieldsJSONComplete}},
|
||||
taxesResponse: provider.ChatResponse{Message: provider.Message{Content: taxesJSONTwoRates}},
|
||||
}
|
||||
res, err := newExtractor(p).Extract(context.Background(), completeInvoiceDoc)
|
||||
if err != nil {
|
||||
t.Fatalf("unerwarteter Fehler: %v", err)
|
||||
}
|
||||
if len(res.Taxes) != 2 {
|
||||
t.Fatalf("erwartet 2 steuersaetze, bekommen %d: %+v", len(res.Taxes), res.Taxes)
|
||||
}
|
||||
seen := map[string]string{}
|
||||
for _, tx := range res.Taxes {
|
||||
seen[tx.RatePercent] = tx.Amount
|
||||
}
|
||||
if seen["19"] != "190.00" || seen["7"] != "7.00" {
|
||||
t.Fatalf("steuersaetze falsch zugeordnet: %+v", seen)
|
||||
}
|
||||
}
|
||||
|
||||
// AK3 + Prüfung 3: Fehlerhafte Rechnung (fehlendes Pflichtfeld) wird nicht
|
||||
// stillschweigend akzeptiert.
|
||||
func TestExtract_MissingRequiredField_RejectedNotSilentlyAccepted(t *testing.T) {
|
||||
broken := `{"invoice_number":{"value":"","present":false,"confidence":0},"total_amount":{"value":"1297.00","present":true,"confidence":0.9},"due_date":{"value":"2026-10-15","present":true,"confidence":0.9},"sender":{"value":"Musterlieferant GmbH","present":true,"confidence":0.9}}`
|
||||
p := &routingProvider{
|
||||
fieldsResponse: provider.ChatResponse{Message: provider.Message{Content: broken}},
|
||||
taxesResponse: provider.ChatResponse{Message: provider.Message{Content: taxesJSONTwoRates}},
|
||||
}
|
||||
res, err := newExtractor(p).Extract(context.Background(), "kaputte rechnung ohne nummer")
|
||||
if err != nil {
|
||||
t.Fatalf("unerwarteter Fehler: %v", err)
|
||||
}
|
||||
if res.Valid {
|
||||
t.Fatal("erwartet Valid=false bei fehlendem Pflichtfeld invoice_number")
|
||||
}
|
||||
if res.RejectReason == "" {
|
||||
t.Fatal("erwartet RejectReason gesetzt")
|
||||
}
|
||||
}
|
||||
|
||||
// AK3: unparsbarer Betrag wird ebenfalls zurückgewiesen, nicht stillschweigend übernommen.
|
||||
func TestExtract_UnparsableAmount_Rejected(t *testing.T) {
|
||||
broken := `{"invoice_number":{"value":"RE-1","present":true,"confidence":0.9},"total_amount":{"value":"ganz viel Geld","present":true,"confidence":0.9},"due_date":{"value":"2026-10-15","present":true,"confidence":0.9},"sender":{"value":"X GmbH","present":true,"confidence":0.9}}`
|
||||
p := &routingProvider{
|
||||
fieldsResponse: provider.ChatResponse{Message: provider.Message{Content: broken}},
|
||||
taxesResponse: provider.ChatResponse{Message: provider.Message{Content: taxesJSONTwoRates}},
|
||||
}
|
||||
res, err := newExtractor(p).Extract(context.Background(), "rechnung mit unlesbarem betrag")
|
||||
if err != nil {
|
||||
t.Fatalf("unerwarteter Fehler: %v", err)
|
||||
}
|
||||
if res.Valid {
|
||||
t.Fatal("erwartet Valid=false bei unlesbarem Betrag")
|
||||
}
|
||||
}
|
||||
|
||||
// AK3: keine erkannten Steuersätze -> ebenfalls zurückgewiesen (Rechnung ohne
|
||||
// jede Steuerangabe ist nicht plausibel vollständig).
|
||||
func TestExtract_NoTaxEntries_Rejected(t *testing.T) {
|
||||
p := &routingProvider{
|
||||
fieldsResponse: provider.ChatResponse{Message: provider.Message{Content: fieldsJSONComplete}},
|
||||
taxesResponse: provider.ChatResponse{Message: provider.Message{Content: `{"taxes":[]}`}},
|
||||
}
|
||||
res, err := newExtractor(p).Extract(context.Background(), "rechnung ohne erkennbare steuerangaben")
|
||||
if err != nil {
|
||||
t.Fatalf("unerwarteter Fehler: %v", err)
|
||||
}
|
||||
if res.Valid {
|
||||
t.Fatal("erwartet Valid=false ohne steuersaetze")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtract_EmptyDocument_Error(t *testing.T) {
|
||||
p := &routingProvider{}
|
||||
if _, err := newExtractor(p).Extract(context.Background(), " "); err == nil {
|
||||
t.Fatal("erwartet Fehler bei leerem Dokument")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtract_NoBase_Error(t *testing.T) {
|
||||
e := &Extractor{Provider: &routingProvider{}, Model: "test"}
|
||||
if _, err := e.Extract(context.Background(), "text"); err == nil {
|
||||
t.Fatal("erwartet Fehler ohne Base-Extractor")
|
||||
}
|
||||
}
|
||||
|
||||
// Prüfung 1: Genauigkeit an gelabeltem Rechnungs-Testset dokumentiert.
|
||||
// Realer Inferenztest gegen den externen Ollama-Server (kein lokaler Ollama
|
||||
// auf 204, wie in dieser Session etabliert).
|
||||
func TestExtract_RealOllama_LabeledInvoiceSet(t *testing.T) {
|
||||
base := os.Getenv("TEST_OLLAMA_BASE_URL")
|
||||
if base == "" {
|
||||
t.Skip("TEST_OLLAMA_BASE_URL nicht gesetzt, echter Inferenztest uebersprungen")
|
||||
}
|
||||
model := os.Getenv("TEST_OLLAMA_MODEL")
|
||||
if model == "" {
|
||||
model = "gpt-oss:20b"
|
||||
}
|
||||
p := ollama.New(base)
|
||||
e := newExtractor(p)
|
||||
e.Model = model
|
||||
e.Base.Model = model
|
||||
|
||||
type labeled struct {
|
||||
name string
|
||||
doc string
|
||||
wantTax int
|
||||
wantVal bool
|
||||
mustHave []string
|
||||
}
|
||||
cases := []labeled{
|
||||
{
|
||||
name: "vollstaendige_rechnung_zwei_steuersaetze",
|
||||
doc: completeInvoiceDoc,
|
||||
wantTax: 2,
|
||||
wantVal: true,
|
||||
mustHave: []string{"RE-2026-0042"},
|
||||
},
|
||||
{
|
||||
name: "kaputte_rechnung_ohne_pflichtangaben",
|
||||
doc: "Dies ist eine Notiz ohne jegliche Rechnungsdaten, kein Betrag, kein Datum, kein Absender.",
|
||||
wantVal: false,
|
||||
},
|
||||
}
|
||||
|
||||
pass := 0
|
||||
for _, c := range cases {
|
||||
res, err := e.Extract(context.Background(), c.doc)
|
||||
if err != nil {
|
||||
t.Errorf("%s: unerwarteter Fehler: %v", c.name, err)
|
||||
continue
|
||||
}
|
||||
ok := res.Valid == c.wantVal
|
||||
if c.wantVal && len(res.Taxes) != c.wantTax {
|
||||
ok = false
|
||||
}
|
||||
for _, want := range c.mustHave {
|
||||
found := false
|
||||
for _, fr := range res.Fields.Fields {
|
||||
if strings.Contains(fr.Value, want) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
ok = false
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
pass++
|
||||
}
|
||||
t.Logf("%s: valid=%v reason=%q taxes=%d fields=%+v", c.name, res.Valid, res.RejectReason, len(res.Taxes), res.Fields.Fields)
|
||||
}
|
||||
t.Logf("Genauigkeit gelabeltes Testset: %d/%d", pass, len(cases))
|
||||
if pass != len(cases) {
|
||||
t.Errorf("erwartet %d/%d korrekt, bekommen %d/%d", len(cases), len(cases), pass, len(cases))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user