feat: add Claude API extraction (internal/extract)

Stufe 1 from the core principle: Client calls the Claude Messages API
directly over net/http (no SDK dependency, stays consistent with
"Go-Standard-Library wo möglich") and forces tool-use with a strict JSON
schema instead of parsing free text. Extract() returns rules.Facts
directly rather than an intermediate DTO, since producing exactly that
is the point of this stage. Platform is supplied by the caller, never
guessed by the model.

Every failure mode returns an error instead of a zero-value Facts:
network errors, non-200 API responses, a missing tool_use block, and —
critically — a gegenleistung value outside the four allowed enum
values, which would otherwise get silently coerced into a wrong fact.
Tested entirely against an httptest fake server, no real API calls.

Building this surfaced a real gap in internal/rules: Evaluate() treated
Consideration=="unklar" the same as any other value, i.e. it just
produced an empty finding list — indistinguishable from "everything's
fine". That contradicts the core principle (uncertain extraction should
trigger a user clarification, never a judgment). Evaluate() now returns
(findings, needsClarification), with a golden case covering it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
noroot
2026-08-27 13:43:01 +02:00
parent bec34b5988
commit da91c3e2c1
6 changed files with 531 additions and 7 deletions

111
internal/extract/api.go Normal file
View File

@@ -0,0 +1,111 @@
package extract
import "encoding/json"
// Diese Typen bilden nur den Ausschnitt der Anthropic-Messages-API ab,
// den die Extraktion tatsächlich braucht (Tool-Use für strukturierte
// Ausgabe) — kein vollständiger API-Client.
type messageRequest struct {
Model string `json:"model"`
MaxTokens int `json:"max_tokens"`
System string `json:"system,omitempty"`
Messages []message `json:"messages"`
Tools []tool `json:"tools"`
ToolChoice toolChoice `json:"tool_choice"`
}
type message struct {
Role string `json:"role"`
Content []contentBlock `json:"content"`
}
type contentBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
Source *imageSource `json:"source,omitempty"`
}
type imageSource struct {
Type string `json:"type"`
MediaType string `json:"media_type"`
Data string `json:"data"`
}
type tool struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema toolInputSchema `json:"input_schema"`
}
type toolInputSchema struct {
Type string `json:"type"`
Properties map[string]any `json:"properties"`
Required []string `json:"required"`
}
type toolChoice struct {
Type string `json:"type"`
Name string `json:"name"`
}
type messageResponse struct {
Content []responseBlock `json:"content"`
Error *apiError `json:"error,omitempty"`
}
type responseBlock struct {
Type string `json:"type"`
Name string `json:"name,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
}
type apiError struct {
Type string `json:"type"`
Message string `json:"message"`
}
// extractionArgs spiegelt extractionTool.InputSchema — das JSON, das
// die Extraktion vom Modell zurückbekommt.
type extractionArgs struct {
Consideration string `json:"gegenleistung"`
DisclosurePresent bool `json:"kennzeichnung_vorhanden"`
DisclosureWording string `json:"kennzeichnung_wortlaut"`
DisclosureBeforeCut bool `json:"kennzeichnung_vor_kuerzung"`
}
var extractionTool = tool{
Name: "extrahiere_fakten",
Description: "Extrahiere ausschließlich beobachtbare Fakten aus Caption und Bild eines " +
"Social-Media-Beitrags für eine Kennzeichnungsprüfung. Triff KEINE rechtliche " +
"Bewertung. Ist ein Feld nicht sicher zu bestimmen, wähle den vorgesehenen " +
"Unsicherheitswert statt zu raten.",
InputSchema: toolInputSchema{
Type: "object",
Properties: map[string]any{
"gegenleistung": map[string]any{
"type": "string",
"enum": []string{"bezahlt", "sachbezug", "keine", "unklar"},
"description": "Erhält der/die Postende eine Gegenleistung (Geld, Produkt, Einladung)? 'unklar' wenn nicht sicher bestimmbar.",
},
"kennzeichnung_vorhanden": map[string]any{
"type": "boolean",
"description": "Enthält die Caption einen Kennzeichnungshinweis wie 'Werbung' oder 'Anzeige'?",
},
"kennzeichnung_wortlaut": map[string]any{
"type": "string",
"description": "Der exakte Wortlaut der Kennzeichnung, falls vorhanden, sonst leerer String.",
},
"kennzeichnung_vor_kuerzung": map[string]any{
"type": "boolean",
"description": "Ist die Kennzeichnung sichtbar, BEVOR die Plattform die Caption hinter 'mehr anzeigen' kürzt? Ohne Kürzung: true.",
},
},
Required: []string{
"gegenleistung",
"kennzeichnung_vorhanden",
"kennzeichnung_wortlaut",
"kennzeichnung_vor_kuerzung",
},
},
}