diff --git a/internal/extract/api.go b/internal/extract/api.go new file mode 100644 index 0000000..2327a8c --- /dev/null +++ b/internal/extract/api.go @@ -0,0 +1,111 @@ +package extract + +import "encoding/json" + +// Diese Typen bilden nur den Ausschnitt der Anthropic-Messages-API ab, +// den die Extraktion tatsächlich braucht (Tool-Use für strukturierte +// Ausgabe) — kein vollständiger API-Client. + +type messageRequest struct { + Model string `json:"model"` + MaxTokens int `json:"max_tokens"` + System string `json:"system,omitempty"` + Messages []message `json:"messages"` + Tools []tool `json:"tools"` + ToolChoice toolChoice `json:"tool_choice"` +} + +type message struct { + Role string `json:"role"` + Content []contentBlock `json:"content"` +} + +type contentBlock struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + Source *imageSource `json:"source,omitempty"` +} + +type imageSource struct { + Type string `json:"type"` + MediaType string `json:"media_type"` + Data string `json:"data"` +} + +type tool struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema toolInputSchema `json:"input_schema"` +} + +type toolInputSchema struct { + Type string `json:"type"` + Properties map[string]any `json:"properties"` + Required []string `json:"required"` +} + +type toolChoice struct { + Type string `json:"type"` + Name string `json:"name"` +} + +type messageResponse struct { + Content []responseBlock `json:"content"` + Error *apiError `json:"error,omitempty"` +} + +type responseBlock struct { + Type string `json:"type"` + Name string `json:"name,omitempty"` + Input json.RawMessage `json:"input,omitempty"` +} + +type apiError struct { + Type string `json:"type"` + Message string `json:"message"` +} + +// extractionArgs spiegelt extractionTool.InputSchema — das JSON, das +// die Extraktion vom Modell zurückbekommt. +type extractionArgs struct { + Consideration string `json:"gegenleistung"` + DisclosurePresent bool `json:"kennzeichnung_vorhanden"` + DisclosureWording string `json:"kennzeichnung_wortlaut"` + DisclosureBeforeCut bool `json:"kennzeichnung_vor_kuerzung"` +} + +var extractionTool = tool{ + Name: "extrahiere_fakten", + Description: "Extrahiere ausschließlich beobachtbare Fakten aus Caption und Bild eines " + + "Social-Media-Beitrags für eine Kennzeichnungsprüfung. Triff KEINE rechtliche " + + "Bewertung. Ist ein Feld nicht sicher zu bestimmen, wähle den vorgesehenen " + + "Unsicherheitswert statt zu raten.", + InputSchema: toolInputSchema{ + Type: "object", + Properties: map[string]any{ + "gegenleistung": map[string]any{ + "type": "string", + "enum": []string{"bezahlt", "sachbezug", "keine", "unklar"}, + "description": "Erhält der/die Postende eine Gegenleistung (Geld, Produkt, Einladung)? 'unklar' wenn nicht sicher bestimmbar.", + }, + "kennzeichnung_vorhanden": map[string]any{ + "type": "boolean", + "description": "Enthält die Caption einen Kennzeichnungshinweis wie 'Werbung' oder 'Anzeige'?", + }, + "kennzeichnung_wortlaut": map[string]any{ + "type": "string", + "description": "Der exakte Wortlaut der Kennzeichnung, falls vorhanden, sonst leerer String.", + }, + "kennzeichnung_vor_kuerzung": map[string]any{ + "type": "boolean", + "description": "Ist die Kennzeichnung sichtbar, BEVOR die Plattform die Caption hinter 'mehr anzeigen' kürzt? Ohne Kürzung: true.", + }, + }, + Required: []string{ + "gegenleistung", + "kennzeichnung_vorhanden", + "kennzeichnung_wortlaut", + "kennzeichnung_vor_kuerzung", + }, + }, +} diff --git a/internal/extract/client.go b/internal/extract/client.go new file mode 100644 index 0000000..6b6b1fc --- /dev/null +++ b/internal/extract/client.go @@ -0,0 +1,199 @@ +// Package extract ist Stufe 1 aus dem Kernprinzip: es extrahiert +// ausschließlich beobachtbare Fakten aus Caption und Bild eines +// Beitrags über die Claude API. Es bewertet nichts — das Urteil fällt +// ausschließlich das Regelwerk in internal/rules. +package extract + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + + "github.com/netcell-it/deklarix/internal/rules" +) + +const ( + defaultBaseURL = "https://api.anthropic.com" + defaultModel = "claude-sonnet-5" + anthropicVersion = "2023-06-01" + + // PromptVersion wird zusammen mit jeder Extraktion gespeichert + // (siehe Datenmodell: extraction.prompt_version), damit sich ein + // späteres Finding auf den exakten Prompt-Stand zurückführen lässt, + // der es erzeugt hat. Hochzählen bei jeder inhaltlichen Änderung an + // systemPrompt oder extractionTool. + PromptVersion = "v1" +) + +const systemPrompt = `Du extrahierst ausschließlich beobachtbare Fakten aus einem Social-Media-Beitrag (Caption und Bild) für eine Kennzeichnungsprüfung nach deutschem Recht. + +Du triffst KEINE rechtliche Bewertung und nennst KEINE Gesetze, Paragraphen oder Urteile — das ist nicht deine Aufgabe. + +Ist ein Fakt nicht sicher aus Caption oder Bild zu bestimmen, wähle den dafür vorgesehenen Unsicherheitswert (z. B. "unklar"). Rate niemals.` + +// Input ist, was Stufe 1 zur Extraktion braucht. Platform kommt vom +// Aufrufer (der Nutzer wählt die Plattform beim Einreichen) statt vom +// Modell erraten zu werden. +type Input struct { + Platform string + Caption string + ImageMediaType string // z. B. "image/jpeg", "image/png" + ImageData []byte +} + +// Client ruft die Claude API zur Fakten-Extraktion auf. +type Client struct { + apiKey string + model string + baseURL string + httpClient *http.Client +} + +// Option konfiguriert einen Client. +type Option func(*Client) + +// WithModel überschreibt das Standardmodell. +func WithModel(model string) Option { + return func(c *Client) { c.model = model } +} + +// WithBaseURL überschreibt die API-Basis-URL (für Tests). +func WithBaseURL(url string) Option { + return func(c *Client) { c.baseURL = url } +} + +// WithHTTPClient überschreibt den verwendeten *http.Client (für Tests). +func WithHTTPClient(hc *http.Client) Option { + return func(c *Client) { c.httpClient = hc } +} + +// NewClient erstellt einen Extraktions-Client. apiKey darf nicht leer +// sein — es gibt keinen stillen Fallback auf einen ungültigen Zustand. +func NewClient(apiKey string, opts ...Option) (*Client, error) { + if apiKey == "" { + return nil, fmt.Errorf("extract: apiKey darf nicht leer sein") + } + c := &Client{ + apiKey: apiKey, + model: defaultModel, + baseURL: defaultBaseURL, + httpClient: http.DefaultClient, + } + for _, opt := range opts { + opt(c) + } + return c, nil +} + +// ModelVersion ist der Modell-Identifier, der zusammen mit jeder +// Extraktion gespeichert werden sollte (siehe Datenmodell: +// extraction.model_version). +func (c *Client) ModelVersion() string { return c.model } + +// Extract ruft die Claude API auf und liefert die extrahierten Fakten. +// Bei jedem Fehler (Netzwerk, API-Fehler, unerwartete Antwortform, +// ungültiger Enum-Wert) wird ein Fehler zurückgegeben statt stumm ein +// Zero-Value-Facts zu liefern — ein falsches "keine Gegenleistung" wäre +// hier schlimmer als ein sichtbarer Fehler. +func (c *Client) Extract(ctx context.Context, in Input) (rules.Facts, error) { + if in.Caption == "" && len(in.ImageData) == 0 { + return rules.Facts{}, fmt.Errorf("extract: caption und bild sind beide leer") + } + + var content []contentBlock + if len(in.ImageData) > 0 { + if in.ImageMediaType == "" { + return rules.Facts{}, fmt.Errorf("extract: ImageMediaType fehlt für vorhandenes Bild") + } + content = append(content, contentBlock{ + Type: "image", + Source: &imageSource{ + Type: "base64", + MediaType: in.ImageMediaType, + Data: base64.StdEncoding.EncodeToString(in.ImageData), + }, + }) + } + content = append(content, contentBlock{Type: "text", Text: "Caption:\n" + in.Caption}) + + reqBody := messageRequest{ + Model: c.model, + MaxTokens: 1024, + System: systemPrompt, + Messages: []message{{Role: "user", Content: content}}, + Tools: []tool{extractionTool}, + ToolChoice: toolChoice{Type: "tool", Name: extractionTool.Name}, + } + + payload, err := json.Marshal(reqBody) + if err != nil { + return rules.Facts{}, fmt.Errorf("extract: request marshal: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/messages", bytes.NewReader(payload)) + if err != nil { + return rules.Facts{}, fmt.Errorf("extract: request bauen: %w", err) + } + req.Header.Set("content-type", "application/json") + req.Header.Set("x-api-key", c.apiKey) + req.Header.Set("anthropic-version", anthropicVersion) + + resp, err := c.httpClient.Do(req) + if err != nil { + return rules.Facts{}, fmt.Errorf("extract: request fehlgeschlagen: %w", err) + } + defer resp.Body.Close() + + var msg messageResponse + if err := json.NewDecoder(resp.Body).Decode(&msg); err != nil { + return rules.Facts{}, fmt.Errorf("extract: response decode: %w", err) + } + + if resp.StatusCode != http.StatusOK { + if msg.Error != nil { + return rules.Facts{}, fmt.Errorf("extract: API-Fehler (%s): %s", msg.Error.Type, msg.Error.Message) + } + return rules.Facts{}, fmt.Errorf("extract: API-Status %d", resp.StatusCode) + } + + var toolUse *responseBlock + for i := range msg.Content { + if msg.Content[i].Type == "tool_use" && msg.Content[i].Name == extractionTool.Name { + toolUse = &msg.Content[i] + break + } + } + if toolUse == nil { + return rules.Facts{}, fmt.Errorf("extract: keine tool_use-Antwort für %q enthalten", extractionTool.Name) + } + + var args extractionArgs + if err := json.Unmarshal(toolUse.Input, &args); err != nil { + return rules.Facts{}, fmt.Errorf("extract: tool-input parse: %w", err) + } + + consideration, err := parseConsideration(args.Consideration) + if err != nil { + return rules.Facts{}, fmt.Errorf("extract: %w", err) + } + + return rules.Facts{ + Platform: in.Platform, + Consideration: consideration, + DisclosurePresent: args.DisclosurePresent, + DisclosureWording: args.DisclosureWording, + DisclosureBeforeCut: args.DisclosureBeforeCut, + }, nil +} + +func parseConsideration(s string) (rules.Consideration, error) { + switch rules.Consideration(s) { + case rules.ConsiderationPaid, rules.ConsiderationInKind, rules.ConsiderationNone, rules.ConsiderationUnclear: + return rules.Consideration(s), nil + default: + return "", fmt.Errorf("unerwarteter gegenleistung-Wert %q vom Modell", s) + } +} diff --git a/internal/extract/client_test.go b/internal/extract/client_test.go new file mode 100644 index 0000000..e2a639e --- /dev/null +++ b/internal/extract/client_test.go @@ -0,0 +1,188 @@ +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) + } +} diff --git a/internal/rules/evaluate.go b/internal/rules/evaluate.go index 51f1b46..a245494 100644 --- a/internal/rules/evaluate.go +++ b/internal/rules/evaluate.go @@ -12,8 +12,17 @@ type Finding struct { // Evaluate prüft alle Regeln gegen f und liefert ein Finding für jede // zutreffende Regel. Reihenfolge folgt der Reihenfolge von rules. -func Evaluate(rules []Rule, f Facts) []Finding { - var findings []Finding +// +// Ist f.Consideration "unklar", wird KEINE Bewertung abgegeben — +// needsClarification ist dann true und findings ist immer leer. Das ist +// Absicht (Kernprinzip): eine unsichere Extraktion erzeugt eine +// Rückfrage an den Nutzer, niemals eine stille "keine Findings"- +// Bewertung, die wie "alles in Ordnung" aussähe. +func Evaluate(rules []Rule, f Facts) (findings []Finding, needsClarification bool) { + if f.Consideration == ConsiderationUnclear { + return nil, true + } + for _, r := range rules { if r.Condition.Matches(f) { findings = append(findings, Finding{ @@ -26,5 +35,5 @@ func Evaluate(rules []Rule, f Facts) []Finding { }) } } - return findings + return findings, false } diff --git a/internal/rules/golden_test.go b/internal/rules/golden_test.go index b98ea52..55a8b21 100644 --- a/internal/rules/golden_test.go +++ b/internal/rules/golden_test.go @@ -16,9 +16,10 @@ import ( // dafür liefern muss. Das ist das eigentliche Asset des Projekts, nicht // die UI — siehe CLAUDE.md. type goldenCase struct { - Name string `json:"name"` - Facts rules.Facts `json:"facts"` - ExpectedFindings []goldenFinding `json:"expected_findings"` + Name string `json:"name"` + Facts rules.Facts `json:"facts"` + ExpectedFindings []goldenFinding `json:"expected_findings"` + ExpectNeedsClarification bool `json:"expect_needs_clarification"` } type goldenFinding struct { @@ -60,7 +61,11 @@ func TestGolden(t *testing.T) { t.Fatalf("parse %s: %v", entry.Name(), err) } - got := rules.Evaluate(ruleSet, gc.Facts) + got, needsClarification := rules.Evaluate(ruleSet, gc.Facts) + + if needsClarification != gc.ExpectNeedsClarification { + t.Fatalf("%s: needsClarification = %v, want %v", gc.Name, needsClarification, gc.ExpectNeedsClarification) + } gotKeys := make([]string, 0, len(got)) for _, f := range got { diff --git a/testdata/golden/unclear-consideration-needs-clarification.json b/testdata/golden/unclear-consideration-needs-clarification.json new file mode 100644 index 0000000..8994cef --- /dev/null +++ b/testdata/golden/unclear-consideration-needs-clarification.json @@ -0,0 +1,12 @@ +{ + "name": "Gegenleistung unklar - Rueckfrage statt Bewertung", + "facts": { + "platform": "instagram", + "consideration": "unklar", + "disclosure_present": false, + "disclosure_wording": "", + "disclosure_before_cut": false + }, + "expected_findings": [], + "expect_needs_clarification": true +}