diff --git a/ai/docs/EXT-02-PRUEFPROTOKOLL.md b/ai/docs/EXT-02-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..b25ab51a --- /dev/null +++ b/ai/docs/EXT-02-PRUEFPROTOKOLL.md @@ -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. diff --git a/ai/internal/invoiceextract/invoiceextract.go b/ai/internal/invoiceextract/invoiceextract.go new file mode 100644 index 00000000..dba24dfb --- /dev/null +++ b/ai/internal/invoiceextract/invoiceextract.go @@ -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] +} diff --git a/ai/internal/invoiceextract/invoiceextract_test.go b/ai/internal/invoiceextract/invoiceextract_test.go new file mode 100644 index 00000000..1b6871f3 --- /dev/null +++ b/ai/internal/invoiceextract/invoiceextract_test.go @@ -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)) + } +}