Rule now carries a required Jurisdiction (land) field, and Facts a matching Jurisdiction supplied by the caller (like Platform — no legal jurisdiction can be read off a caption or image, so the model never guesses it). Evaluate() only lets a rule fire when its jurisdiction matches the facts' jurisdiction. This is the structural half of "deutsche Rechtslage zuerst, Struktur für Österreich und Schweiz vorgesehen": a future AT/CH rule set can be added as plain new YAML files without touching existing DE rules, but no AT/CH content is added now — that needs its own legal research first, same as WK-001/WK-004 needed for Germany. WK-001 and WK-004 are tagged land: DE, all golden fixtures carry jurisdiction: DE, and extract.Input passes Jurisdiction through unchanged into the returned Facts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
191 lines
5.9 KiB
Go
191 lines
5.9 KiB
Go
package extract_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/netcell-it/deklarix/internal/extract"
|
|
"github.com/netcell-it/deklarix/internal/rules"
|
|
)
|
|
|
|
// toolUseResponse baut eine minimale Anthropic-Messages-API-Antwort mit
|
|
// genau einem tool_use-Block, wie sie extract.Client erwartet.
|
|
func toolUseResponse(t *testing.T, toolName string, args any) []byte {
|
|
t.Helper()
|
|
input, err := json.Marshal(args)
|
|
if err != nil {
|
|
t.Fatalf("marshal args: %v", err)
|
|
}
|
|
body := map[string]any{
|
|
"content": []map[string]any{
|
|
{"type": "tool_use", "name": toolName, "input": json.RawMessage(input)},
|
|
},
|
|
}
|
|
data, err := json.Marshal(body)
|
|
if err != nil {
|
|
t.Fatalf("marshal response: %v", err)
|
|
}
|
|
return data
|
|
}
|
|
|
|
func newTestClient(t *testing.T, handler http.HandlerFunc) *extract.Client {
|
|
t.Helper()
|
|
server := httptest.NewServer(handler)
|
|
t.Cleanup(server.Close)
|
|
c, err := extract.NewClient("test-key", extract.WithBaseURL(server.URL))
|
|
if err != nil {
|
|
t.Fatalf("NewClient: %v", err)
|
|
}
|
|
return c
|
|
}
|
|
|
|
func TestNewClientRejectsEmptyAPIKey(t *testing.T) {
|
|
if _, err := extract.NewClient(""); err == nil {
|
|
t.Fatal("expected error for empty apiKey, got nil")
|
|
}
|
|
}
|
|
|
|
func TestExtractRejectsEmptyInput(t *testing.T) {
|
|
called := false
|
|
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
|
called = true
|
|
})
|
|
_, err := c.Extract(context.Background(), extract.Input{Platform: "instagram"})
|
|
if err == nil {
|
|
t.Fatal("expected error for empty caption+image, got nil")
|
|
}
|
|
if called {
|
|
t.Fatal("expected no HTTP call for invalid input, but the server was called")
|
|
}
|
|
}
|
|
|
|
func TestExtractSuccess(t *testing.T) {
|
|
var gotBody map[string]any
|
|
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/v1/messages" {
|
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
|
}
|
|
if got := r.Header.Get("x-api-key"); got != "test-key" {
|
|
t.Errorf("x-api-key = %q, want test-key", got)
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
|
|
t.Fatalf("decode request body: %v", err)
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write(toolUseResponse(t, "extrahiere_fakten", map[string]any{
|
|
"gegenleistung": "bezahlt",
|
|
"kennzeichnung_vorhanden": true,
|
|
"kennzeichnung_wortlaut": "Werbung",
|
|
"kennzeichnung_vor_kuerzung": false,
|
|
}))
|
|
})
|
|
|
|
got, err := c.Extract(context.Background(), extract.Input{
|
|
Platform: "instagram",
|
|
Jurisdiction: "DE",
|
|
Caption: "Schaut euch dieses Produkt an! Werbung wegen ...",
|
|
ImageMediaType: "image/jpeg",
|
|
ImageData: []byte("fake-jpeg-bytes"),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Extract: %v", err)
|
|
}
|
|
|
|
want := rules.Facts{
|
|
Platform: "instagram",
|
|
Jurisdiction: "DE",
|
|
Consideration: rules.ConsiderationPaid,
|
|
DisclosurePresent: true,
|
|
DisclosureWording: "Werbung",
|
|
DisclosureBeforeCut: false,
|
|
}
|
|
if got != want {
|
|
t.Fatalf("Extract() = %+v, want %+v", got, want)
|
|
}
|
|
|
|
toolChoice, _ := gotBody["tool_choice"].(map[string]any)
|
|
if toolChoice["name"] != "extrahiere_fakten" {
|
|
t.Errorf("tool_choice.name = %v, want extrahiere_fakten", toolChoice["name"])
|
|
}
|
|
if gotBody["system"] == nil || gotBody["system"] == "" {
|
|
t.Error("expected a non-empty system prompt in the request")
|
|
}
|
|
}
|
|
|
|
func TestExtractPassesThroughUnclear(t *testing.T) {
|
|
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write(toolUseResponse(t, "extrahiere_fakten", map[string]any{
|
|
"gegenleistung": "unklar",
|
|
"kennzeichnung_vorhanden": false,
|
|
"kennzeichnung_wortlaut": "",
|
|
"kennzeichnung_vor_kuerzung": false,
|
|
}))
|
|
})
|
|
|
|
got, err := c.Extract(context.Background(), extract.Input{Platform: "tiktok", Caption: "..."})
|
|
if err != nil {
|
|
t.Fatalf("Extract: %v", err)
|
|
}
|
|
if got.Consideration != rules.ConsiderationUnclear {
|
|
t.Fatalf("Consideration = %q, want unklar", got.Consideration)
|
|
}
|
|
}
|
|
|
|
func TestExtractRejectsInvalidConsiderationValue(t *testing.T) {
|
|
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write(toolUseResponse(t, "extrahiere_fakten", map[string]any{
|
|
"gegenleistung": "vielleicht", // nicht im Enum
|
|
"kennzeichnung_vorhanden": false,
|
|
"kennzeichnung_wortlaut": "",
|
|
"kennzeichnung_vor_kuerzung": false,
|
|
}))
|
|
})
|
|
|
|
if _, err := c.Extract(context.Background(), extract.Input{Platform: "instagram", Caption: "x"}); err == nil {
|
|
t.Fatal("expected error for out-of-enum gegenleistung value, got nil")
|
|
}
|
|
}
|
|
|
|
func TestExtractRejectsMissingToolUseBlock(t *testing.T) {
|
|
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"content":[{"type":"text","text":"kein tool_use hier"}]}`))
|
|
})
|
|
|
|
if _, err := c.Extract(context.Background(), extract.Input{Platform: "instagram", Caption: "x"}); err == nil {
|
|
t.Fatal("expected error when response has no tool_use block, got nil")
|
|
}
|
|
}
|
|
|
|
func TestExtractRejectsMalformedToolInput(t *testing.T) {
|
|
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"content":[{"type":"tool_use","name":"extrahiere_fakten","input":"not-an-object"}]}`))
|
|
})
|
|
|
|
if _, err := c.Extract(context.Background(), extract.Input{Platform: "instagram", Caption: "x"}); err == nil {
|
|
t.Fatal("expected error for malformed tool input, got nil")
|
|
}
|
|
}
|
|
|
|
func TestExtractPropagatesAPIError(t *testing.T) {
|
|
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
w.Write([]byte(`{"error":{"type":"rate_limit_error","message":"too many requests"}}`))
|
|
})
|
|
|
|
_, err := c.Extract(context.Background(), extract.Input{Platform: "instagram", Caption: "x"})
|
|
if err == nil {
|
|
t.Fatal("expected error for non-200 status, got nil")
|
|
}
|
|
if got := err.Error(); !strings.Contains(got, "too many requests") {
|
|
t.Fatalf("error %q does not mention API message", got)
|
|
}
|
|
}
|