// 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 und // Jurisdiction kommen vom Aufrufer (der Nutzer wählt Plattform und // Rechtsordnung beim Einreichen) statt vom Modell erraten zu werden — // aus Caption/Bild lässt sich keine Rechtsordnung ablesen. type Input struct { Platform string Jurisdiction string Caption string ImageMediaType string // z. B. "image/jpeg", "image/png" ImageData []byte } // Result ist die Ausgabe von Extract: die für das Regelwerk // aufbereiteten Facts, plus RawJSON — das exakte, unveränderte JSON, das // das Modell zurückgegeben hat. RawJSON gehört unverändert in // extraction.payload (siehe Datenmodell); Facts ist eine abgeleitete // Sicht darauf und nicht der Beweis-Eintrag selbst. type Result struct { Facts rules.Facts RawJSON []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) (Result, error) { if in.Caption == "" && len(in.ImageData) == 0 { return Result{}, fmt.Errorf("extract: caption und bild sind beide leer") } var content []contentBlock if len(in.ImageData) > 0 { if in.ImageMediaType == "" { return Result{}, 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 Result{}, 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 Result{}, 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 Result{}, 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 Result{}, fmt.Errorf("extract: response decode: %w", err) } if resp.StatusCode != http.StatusOK { if msg.Error != nil { return Result{}, fmt.Errorf("extract: API-Fehler (%s): %s", msg.Error.Type, msg.Error.Message) } return Result{}, 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 Result{}, fmt.Errorf("extract: keine tool_use-Antwort für %q enthalten", extractionTool.Name) } facts, err := parseArgsToFacts(toolUse.Input, in.Platform, in.Jurisdiction) if err != nil { return Result{}, fmt.Errorf("extract: %w", err) } return Result{Facts: facts, RawJSON: toolUse.Input}, nil } // ParsePayload rekonstruiert Facts aus einem zuvor gespeicherten // RawJSON-Payload (z. B. aus extraction.payload). platform/jurisdiction // müssen erneut mitgegeben werden — sie sind, wie bei Extract, nie Teil // des Modell-Payloads. func ParsePayload(payload []byte, platform, jurisdiction string) (rules.Facts, error) { facts, err := parseArgsToFacts(payload, platform, jurisdiction) if err != nil { return rules.Facts{}, fmt.Errorf("extract: %w", err) } return facts, nil } func parseArgsToFacts(raw []byte, platform, jurisdiction string) (rules.Facts, error) { var args extractionArgs if err := json.Unmarshal(raw, &args); err != nil { return rules.Facts{}, fmt.Errorf("tool-input parse: %w", err) } consideration, err := parseConsideration(args.Consideration) if err != nil { return rules.Facts{}, err } return rules.Facts{ Platform: platform, Jurisdiction: jurisdiction, 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) } }