feat: add Claude API extraction (internal/extract)
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>
This commit is contained in:
111
internal/extract/api.go
Normal file
111
internal/extract/api.go
Normal file
@@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
199
internal/extract/client.go
Normal file
199
internal/extract/client.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
188
internal/extract/client_test.go
Normal file
188
internal/extract/client_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,8 +12,17 @@ type Finding struct {
|
|||||||
|
|
||||||
// Evaluate prüft alle Regeln gegen f und liefert ein Finding für jede
|
// Evaluate prüft alle Regeln gegen f und liefert ein Finding für jede
|
||||||
// zutreffende Regel. Reihenfolge folgt der Reihenfolge von rules.
|
// 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 {
|
for _, r := range rules {
|
||||||
if r.Condition.Matches(f) {
|
if r.Condition.Matches(f) {
|
||||||
findings = append(findings, Finding{
|
findings = append(findings, Finding{
|
||||||
@@ -26,5 +35,5 @@ func Evaluate(rules []Rule, f Facts) []Finding {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return findings
|
return findings, false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type goldenCase struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Facts rules.Facts `json:"facts"`
|
Facts rules.Facts `json:"facts"`
|
||||||
ExpectedFindings []goldenFinding `json:"expected_findings"`
|
ExpectedFindings []goldenFinding `json:"expected_findings"`
|
||||||
|
ExpectNeedsClarification bool `json:"expect_needs_clarification"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type goldenFinding struct {
|
type goldenFinding struct {
|
||||||
@@ -60,7 +61,11 @@ func TestGolden(t *testing.T) {
|
|||||||
t.Fatalf("parse %s: %v", entry.Name(), err)
|
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))
|
gotKeys := make([]string, 0, len(got))
|
||||||
for _, f := range got {
|
for _, f := range got {
|
||||||
|
|||||||
12
testdata/golden/unclear-consideration-needs-clarification.json
vendored
Normal file
12
testdata/golden/unclear-consideration-needs-clarification.json
vendored
Normal file
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user