Files
nexarch/ai/internal/extractapi/extractapi_test.go
patrickandClaude Sonnet 5 30cddd6e85 EXT-04: extraktions-korrektur-oberflaeche (Backend)
Neues Paket ai/internal/extractapi als minimale HTTP-Bindung fuer die
Rechnungsdaten-Extraktion (EXT-02) samt Plausibilitaetspruefung/
Confidence-Score (EXT-03), analog zu summaryapi/semanticsearchapi.
Service-Token-Pflicht, fail closed. Kein bestehender Code aus
EXT-01/EXT-02/EXT-03 veraendert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 12:47:55 +02:00

144 lines
5.1 KiB
Go

package extractapi_test
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/extract"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/extractapi"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/extractvalidate"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/invoiceextract"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider"
)
const validInvoiceModelResponse = `{
"invoice_number": {"value": "2026-0042", "present": true, "confidence": 0.95},
"total_amount": {"value": "12450.00", "present": true, "confidence": 0.9},
"due_date": {"value": "2026-01-17", "present": true, "confidence": 0.8},
"sender": {"value": "Musterfirma GmbH", "present": true, "confidence": 0.85}
}`
type fixedProvider struct{ response string }
func (p *fixedProvider) Chat(context.Context, provider.ChatRequest) (provider.ChatResponse, error) {
return provider.ChatResponse{Message: provider.Message{Content: p.response}}, nil
}
func (p *fixedProvider) Complete(context.Context, provider.CompletionRequest) (provider.CompletionResponse, error) {
return provider.CompletionResponse{}, nil
}
func (p *fixedProvider) Embed(context.Context, provider.EmbeddingRequest) (provider.EmbeddingResponse, error) {
return provider.EmbeddingResponse{}, nil
}
func newTestServer(resp string) *extractapi.Server {
prov := &fixedProvider{response: resp}
extractor := &invoiceextract.Extractor{
Base: &extract.Extractor{Provider: prov, Model: "test-model"},
Provider: prov,
Model: "test-model",
}
rules := extractvalidate.RuleSet{
{Field: "total_amount", Kind: extractvalidate.RuleKindNumberRange, Min: floatPtr(0)},
}
return extractapi.NewServer(extractor, rules, "geheimes-token")
}
func floatPtr(f float64) *float64 { return &f }
// AK1: Vorschlaege werden mit Konfidenz angezeigt.
func TestHandleInvoiceExtract_ReturnsFieldsWithConfidence(t *testing.T) {
mux := http.NewServeMux()
newTestServer(validInvoiceModelResponse).RegisterRoutes(mux)
body, _ := json.Marshal(map[string]any{"text": "Rechnung 2026-0042 ueber 12450 EUR."})
req := httptest.NewRequest(http.MethodPost, "/invoices/extract", bytes.NewReader(body))
req.Header.Set("X-Service-Token", "geheimes-token")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("erwartet 200, bekommen %d: %s", rec.Code, rec.Body.String())
}
var resp struct {
Fields map[string]struct {
Value string `json:"value"`
Confidence float64 `json:"confidence"`
Plausible bool `json:"plausible"`
} `json:"fields"`
OverallScore float64 `json:"overall_score"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("antwort nicht json: %v", err)
}
inv, ok := resp.Fields["invoice_number"]
if !ok || inv.Value != "2026-0042" {
t.Fatalf("erwartet invoice_number 2026-0042, bekommen %+v", resp.Fields)
}
if inv.Confidence <= 0 {
t.Fatalf("erwartet konfidenz > 0, bekommen %v", inv.Confidence)
}
if resp.OverallScore <= 0 {
t.Fatalf("erwartet gesamt-score > 0, bekommen %v", resp.OverallScore)
}
}
func TestHandleInvoiceExtract_UnplausibleValueMarkedNotSilent(t *testing.T) {
mux := http.NewServeMux()
resp := `{
"invoice_number": {"value": "2026-0042", "present": true, "confidence": 0.9},
"total_amount": {"value": "-5.00", "present": true, "confidence": 0.9},
"due_date": {"value": "2026-01-17", "present": true, "confidence": 0.9},
"sender": {"value": "Musterfirma GmbH", "present": true, "confidence": 0.9}
}`
newTestServer(resp).RegisterRoutes(mux)
body, _ := json.Marshal(map[string]any{"text": "Rechnung mit negativem Betrag."})
req := httptest.NewRequest(http.MethodPost, "/invoices/extract", bytes.NewReader(body))
req.Header.Set("X-Service-Token", "geheimes-token")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
var out struct {
Fields map[string]struct {
Plausible bool `json:"plausible"`
} `json:"fields"`
}
json.Unmarshal(rec.Body.Bytes(), &out)
if out.Fields["total_amount"].Plausible {
t.Fatalf("erwartet total_amount als unplausibel markiert, war plausible=true")
}
}
func TestHandleInvoiceExtract_RequiresServiceToken(t *testing.T) {
mux := http.NewServeMux()
newTestServer(validInvoiceModelResponse).RegisterRoutes(mux)
body, _ := json.Marshal(map[string]any{"text": "Text"})
req := httptest.NewRequest(http.MethodPost, "/invoices/extract", bytes.NewReader(body))
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("erwartet 401 ohne service-token, bekommen %d", rec.Code)
}
}
func TestHandleInvoiceExtract_RejectsEmptyText(t *testing.T) {
mux := http.NewServeMux()
newTestServer(validInvoiceModelResponse).RegisterRoutes(mux)
body, _ := json.Marshal(map[string]any{"text": ""})
req := httptest.NewRequest(http.MethodPost, "/invoices/extract", bytes.NewReader(body))
req.Header.Set("X-Service-Token", "geheimes-token")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("erwartet 400 bei leerem text, bekommen %d", rec.Code)
}
}