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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user