Stufe 1 from the core principle: Client calls the Claude Messages API directly over net/http (no SDK dependency, stays consistent with "Go-Standard-Library wo möglich") and forces tool-use with a strict JSON schema instead of parsing free text. Extract() returns rules.Facts directly rather than an intermediate DTO, since producing exactly that is the point of this stage. Platform is supplied by the caller, never guessed by the model. Every failure mode returns an error instead of a zero-value Facts: network errors, non-200 API responses, a missing tool_use block, and — critically — a gegenleistung value outside the four allowed enum values, which would otherwise get silently coerced into a wrong fact. Tested entirely against an httptest fake server, no real API calls. Building this surfaced a real gap in internal/rules: Evaluate() treated Consideration=="unklar" the same as any other value, i.e. it just produced an empty finding list — indistinguishable from "everything's fine". That contradicts the core principle (uncertain extraction should trigger a user clarification, never a judgment). Evaluate() now returns (findings, needsClarification), with a golden case covering it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
189 lines
5.8 KiB
Go
189 lines
5.8 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",
|
|
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",
|
|
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)
|
|
}
|
|
}
|