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>
144 lines
5.1 KiB
Go
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)
|
|
}
|
|
}
|