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>
203 lines
6.6 KiB
Go
203 lines
6.6 KiB
Go
// 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
|
|
}
|
|
|
|
// 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,
|
|
Jurisdiction: in.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)
|
|
}
|
|
}
|