This is the piece that turns a Pre-Publish-Prüfung into an actual
archived, provable record instead of a one-off form response.
extract.Client.Extract now returns Result{Facts, RawJSON} instead of
just Facts — RawJSON is the model's exact, unmodified JSON, which is
what belongs in extraction.payload (the audit trail), not a re-encoded
view through our own Facts struct. extract.ParsePayload reconstructs
Facts from a stored payload later, reusing the same parsing/validation
path Extract uses (including the enum guard), so a previously-saved
extraction can be read back exactly as it would have been the first
time.
internal/dossier.BuildContent no longer requires AssetHash: most checks
right now are caption-only (no image/video upload wired yet), and
inventing a placeholder hash for a nonexistent asset would itself be an
integrity problem in an evidence tool. Content shows "kein Asset
hinterlegt" instead.
internal/web gains a narrow Store interface (mirroring the Extractor
pattern — only the methods these handlers use, not the full
*store.Store) so its test suite stays network/DB-free via an in-memory
fake:
- POST /pruefen persists submission + extraction + findings and marks
the submission "checked". A needsClarification result persists the
extraction (there's something worth keeping) but no findings and no
status change, and the template omits the archive option entirely.
- POST /veroeffentlichen re-derives Facts from the stored payload, hashes
the canonical submission+facts+findings metadata, gets an RFC-3161
timestamp, generates the PDF dossier to disk, and persists the
evidence_package row before marking the submission "published".
- GET /dossier/{id} serves the generated PDF.
Tested end-to-end offline: a fake Timestamper builds a real, structurally
valid self-signed RFC-3161 response so the full check→archive→download
flow runs against an in-memory store, verifying the downloaded bytes are
an actual PDF and the dossier file lands on disk — without hitting a
real database, TSA, or the Claude API.
cmd/deklarix/main.go now wires store.Store, evidence.NewHTTPTimestamper
(TSA_URL, default FreeTSA), and DOSSIER_DIR (default "dossiers") into
web.NewServer alongside the extractor and rule set.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
234 lines
7.7 KiB
Go
234 lines
7.7 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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|