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>
This commit is contained in:
noroot
2026-08-27 14:39:38 +02:00
parent 72005fc326
commit f5ad08cebd
14 changed files with 724 additions and 105 deletions

View File

@@ -46,6 +46,16 @@ type Input struct {
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
@@ -100,15 +110,15 @@ func (c *Client) ModelVersion() string { return c.model }
// 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) {
func (c *Client) Extract(ctx context.Context, in Input) (Result, error) {
if in.Caption == "" && len(in.ImageData) == 0 {
return rules.Facts{}, fmt.Errorf("extract: caption und bild sind beide leer")
return Result{}, 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")
return Result{}, fmt.Errorf("extract: ImageMediaType fehlt für vorhandenes Bild")
}
content = append(content, contentBlock{
Type: "image",
@@ -132,12 +142,12 @@ func (c *Client) Extract(ctx context.Context, in Input) (rules.Facts, error) {
payload, err := json.Marshal(reqBody)
if err != nil {
return rules.Facts{}, fmt.Errorf("extract: request marshal: %w", err)
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 rules.Facts{}, fmt.Errorf("extract: request bauen: %w", err)
return Result{}, fmt.Errorf("extract: request bauen: %w", err)
}
req.Header.Set("content-type", "application/json")
req.Header.Set("x-api-key", c.apiKey)
@@ -145,20 +155,20 @@ func (c *Client) Extract(ctx context.Context, in Input) (rules.Facts, error) {
resp, err := c.httpClient.Do(req)
if err != nil {
return rules.Facts{}, fmt.Errorf("extract: request fehlgeschlagen: %w", err)
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 rules.Facts{}, fmt.Errorf("extract: response decode: %w", err)
return Result{}, 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 Result{}, fmt.Errorf("extract: API-Fehler (%s): %s", msg.Error.Type, msg.Error.Message)
}
return rules.Facts{}, fmt.Errorf("extract: API-Status %d", resp.StatusCode)
return Result{}, fmt.Errorf("extract: API-Status %d", resp.StatusCode)
}
var toolUse *responseBlock
@@ -169,22 +179,43 @@ func (c *Client) Extract(ctx context.Context, in Input) (rules.Facts, error) {
}
}
if toolUse == nil {
return rules.Facts{}, fmt.Errorf("extract: keine tool_use-Antwort für %q enthalten", extractionTool.Name)
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(toolUse.Input, &args); err != nil {
return rules.Facts{}, fmt.Errorf("extract: tool-input parse: %w", err)
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{}, fmt.Errorf("extract: %w", err)
return rules.Facts{}, err
}
return rules.Facts{
Platform: in.Platform,
Jurisdiction: in.Jurisdiction,
Platform: platform,
Jurisdiction: jurisdiction,
Consideration: consideration,
DisclosurePresent: args.DisclosurePresent,
DisclosureWording: args.DisclosureWording,