Merge remote-tracking branch 'origin/feature/ext-02-rechnungsdaten-extraktion' into integration/gesamtprojekt

This commit is contained in:
root committed 2026-09-25 20:37:16 +00:00
commit f3d8d164e6
3 files changed
+490

No files matched your search

+42
View File
@@ -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))
}
}