Files
deklarix/internal/extract/client.go
noroot f5ad08cebd feat: wire persistence into the web layer (check → archive → dossier)
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>
2026-08-27 14:39:38 +02:00

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)
}
}