feat: replace Claude-based extraction with a rule-based engine
Deklarix itself no longer depends on the Anthropic API — that was a separate API key/billing relationship from Claude Code (used to develop Deklarix), which the user did not intend to take on for the product itself. Consideration (Gegenleistung) is no longer guessed from text — it's a required form field now, since only the submitter actually knows whether a business relationship existed. A keyword-only system can't tell a covertly-paid post from a genuinely organic one; they read identically. What internal/extract *can* still determine reliably and deterministically from the caption: whether a disclosure keyword is present (werbung, anzeige, bezahlte partnerschaft, paid partnership, #ad, #werbung, #anzeige, #sponsored, #sponsoredby, #sponsoredpost — case-insensitive), its exact original-case wording, and whether it sits before the platform's "mehr anzeigen" truncation point (~125 chars Instagram, ~150 TikTok — rough estimates, platforms change these without notice, verify before real customer use). internal/extract's Anthropic HTTP client and tool-use schema are gone (client.go/api.go deleted), replaced by engine.go — a stateless Engine with no network calls. extract.Result/ParsePayload keep the exact same JSON shape as before (gegenleistung/kennzeichnung_vorhanden/ kennzeichnung_wortlaut/kennzeichnung_vor_kuerzung), so internal/store and internal/dossier needed no changes at all — only extract itself, the web form/handler (new consideration field), and main.go (no more ANTHROPIC_API_KEY requirement) changed. Trade-off the user was told and accepted: without an LLM, the system can no longer independently catch undisclosed paid content that carries no recognizable keyword at all — that now rests on the submitter's honesty. Creative or implicit disclosure phrasing outside the keyword list also won't be recognized. Verified against a real running instance with zero API keys configured: register -> check (real rule engine, correctly triggered WK-004 for a disclosure placed 130 characters in, past the Instagram threshold) -> archive -> PDF dossier download, all against real Postgres. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,111 +0,0 @@
|
||||
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",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
package extract_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/extract"
|
||||
"github.com/netcell-it/deklarix/internal/rules"
|
||||
)
|
||||
|
||||
// toolUseResponse baut eine minimale Anthropic-Messages-API-Antwort mit
|
||||
// genau einem tool_use-Block, wie sie extract.Client erwartet.
|
||||
func toolUseResponse(t *testing.T, toolName string, args any) []byte {
|
||||
t.Helper()
|
||||
input, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal args: %v", err)
|
||||
}
|
||||
body := map[string]any{
|
||||
"content": []map[string]any{
|
||||
{"type": "tool_use", "name": toolName, "input": json.RawMessage(input)},
|
||||
},
|
||||
}
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal response: %v", err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func newTestClient(t *testing.T, handler http.HandlerFunc) *extract.Client {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(handler)
|
||||
t.Cleanup(server.Close)
|
||||
c, err := extract.NewClient("test-key", extract.WithBaseURL(server.URL))
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient: %v", err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func TestNewClientRejectsEmptyAPIKey(t *testing.T) {
|
||||
if _, err := extract.NewClient(""); err == nil {
|
||||
t.Fatal("expected error for empty apiKey, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRejectsEmptyInput(t *testing.T) {
|
||||
called := false
|
||||
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
})
|
||||
_, err := c.Extract(context.Background(), extract.Input{Platform: "instagram"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty caption+image, got nil")
|
||||
}
|
||||
if called {
|
||||
t.Fatal("expected no HTTP call for invalid input, but the server was called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractSuccess(t *testing.T) {
|
||||
var gotBody map[string]any
|
||||
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/messages" {
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
if got := r.Header.Get("x-api-key"); got != "test-key" {
|
||||
t.Errorf("x-api-key = %q, want test-key", got)
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(toolUseResponse(t, "extrahiere_fakten", map[string]any{
|
||||
"gegenleistung": "bezahlt",
|
||||
"kennzeichnung_vorhanden": true,
|
||||
"kennzeichnung_wortlaut": "Werbung",
|
||||
"kennzeichnung_vor_kuerzung": false,
|
||||
}))
|
||||
})
|
||||
|
||||
got, err := c.Extract(context.Background(), extract.Input{
|
||||
Platform: "instagram",
|
||||
Jurisdiction: "DE",
|
||||
Caption: "Schaut euch dieses Produkt an! Werbung wegen ...",
|
||||
ImageMediaType: "image/jpeg",
|
||||
ImageData: []byte("fake-jpeg-bytes"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Extract: %v", err)
|
||||
}
|
||||
|
||||
want := rules.Facts{
|
||||
Platform: "instagram",
|
||||
Jurisdiction: "DE",
|
||||
Consideration: rules.ConsiderationPaid,
|
||||
DisclosurePresent: true,
|
||||
DisclosureWording: "Werbung",
|
||||
DisclosureBeforeCut: false,
|
||||
}
|
||||
if got.Facts != want {
|
||||
t.Fatalf("Extract().Facts = %+v, want %+v", got.Facts, want)
|
||||
}
|
||||
var rawArgs map[string]any
|
||||
if err := json.Unmarshal(got.RawJSON, &rawArgs); err != nil {
|
||||
t.Fatalf("RawJSON does not parse as JSON: %v", err)
|
||||
}
|
||||
if rawArgs["gegenleistung"] != "bezahlt" {
|
||||
t.Fatalf("RawJSON = %s, expected it to contain the model's raw field names", got.RawJSON)
|
||||
}
|
||||
|
||||
toolChoice, _ := gotBody["tool_choice"].(map[string]any)
|
||||
if toolChoice["name"] != "extrahiere_fakten" {
|
||||
t.Errorf("tool_choice.name = %v, want extrahiere_fakten", toolChoice["name"])
|
||||
}
|
||||
if gotBody["system"] == nil || gotBody["system"] == "" {
|
||||
t.Error("expected a non-empty system prompt in the request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractPassesThroughUnclear(t *testing.T) {
|
||||
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(toolUseResponse(t, "extrahiere_fakten", map[string]any{
|
||||
"gegenleistung": "unklar",
|
||||
"kennzeichnung_vorhanden": false,
|
||||
"kennzeichnung_wortlaut": "",
|
||||
"kennzeichnung_vor_kuerzung": false,
|
||||
}))
|
||||
})
|
||||
|
||||
got, err := c.Extract(context.Background(), extract.Input{Platform: "tiktok", Caption: "..."})
|
||||
if err != nil {
|
||||
t.Fatalf("Extract: %v", err)
|
||||
}
|
||||
if got.Facts.Consideration != rules.ConsiderationUnclear {
|
||||
t.Fatalf("Consideration = %q, want unklar", got.Facts.Consideration)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRejectsInvalidConsiderationValue(t *testing.T) {
|
||||
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(toolUseResponse(t, "extrahiere_fakten", map[string]any{
|
||||
"gegenleistung": "vielleicht", // nicht im Enum
|
||||
"kennzeichnung_vorhanden": false,
|
||||
"kennzeichnung_wortlaut": "",
|
||||
"kennzeichnung_vor_kuerzung": false,
|
||||
}))
|
||||
})
|
||||
|
||||
if _, err := c.Extract(context.Background(), extract.Input{Platform: "instagram", Caption: "x"}); err == nil {
|
||||
t.Fatal("expected error for out-of-enum gegenleistung value, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRejectsMissingToolUseBlock(t *testing.T) {
|
||||
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"content":[{"type":"text","text":"kein tool_use hier"}]}`))
|
||||
})
|
||||
|
||||
if _, err := c.Extract(context.Background(), extract.Input{Platform: "instagram", Caption: "x"}); err == nil {
|
||||
t.Fatal("expected error when response has no tool_use block, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRejectsMalformedToolInput(t *testing.T) {
|
||||
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"content":[{"type":"tool_use","name":"extrahiere_fakten","input":"not-an-object"}]}`))
|
||||
})
|
||||
|
||||
if _, err := c.Extract(context.Background(), extract.Input{Platform: "instagram", Caption: "x"}); err == nil {
|
||||
t.Fatal("expected error for malformed tool input, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePayloadRoundTrip(t *testing.T) {
|
||||
payload := []byte(`{"gegenleistung":"sachbezug","kennzeichnung_vorhanden":true,"kennzeichnung_wortlaut":"Anzeige","kennzeichnung_vor_kuerzung":true}`)
|
||||
|
||||
facts, err := extract.ParsePayload(payload, "tiktok", "DE")
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePayload: %v", err)
|
||||
}
|
||||
|
||||
want := rules.Facts{
|
||||
Platform: "tiktok",
|
||||
Jurisdiction: "DE",
|
||||
Consideration: rules.ConsiderationInKind,
|
||||
DisclosurePresent: true,
|
||||
DisclosureWording: "Anzeige",
|
||||
DisclosureBeforeCut: true,
|
||||
}
|
||||
if facts != want {
|
||||
t.Fatalf("ParsePayload() = %+v, want %+v", facts, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePayloadRejectsInvalidConsideration(t *testing.T) {
|
||||
payload := []byte(`{"gegenleistung":"vielleicht","kennzeichnung_vorhanden":false,"kennzeichnung_wortlaut":"","kennzeichnung_vor_kuerzung":false}`)
|
||||
if _, err := extract.ParsePayload(payload, "tiktok", "DE"); err == nil {
|
||||
t.Fatal("expected error for out-of-enum stored payload, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractPropagatesAPIError(t *testing.T) {
|
||||
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
w.Write([]byte(`{"error":{"type":"rate_limit_error","message":"too many requests"}}`))
|
||||
})
|
||||
|
||||
_, err := c.Extract(context.Background(), extract.Input{Platform: "instagram", Caption: "x"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-200 status, got nil")
|
||||
}
|
||||
if got := err.Error(); !strings.Contains(got, "too many requests") {
|
||||
t.Fatalf("error %q does not mention API message", got)
|
||||
}
|
||||
}
|
||||
198
internal/extract/engine.go
Normal file
198
internal/extract/engine.go
Normal file
@@ -0,0 +1,198 @@
|
||||
// Package extract ist Stufe 1 aus dem Kernprinzip: es bestimmt
|
||||
// ausschließlich beobachtbare Fakten zu einem Beitrag. Es bewertet
|
||||
// nichts — das Urteil fällt ausschließlich das Regelwerk in
|
||||
// internal/rules.
|
||||
//
|
||||
// Die Gegenleistung (Consideration) wird NICHT aus dem Text geraten —
|
||||
// aus reinem Keyword-Matching lässt sich eine verschwiegene
|
||||
// Zusammenarbeit nicht von einem echten organischen Post unterscheiden
|
||||
// (beide sehen textlich identisch aus). Nur wer den Beitrag einreicht,
|
||||
// weiß, ob eine Gegenleistung vorlag, darum kommt dieser Wert vom
|
||||
// Aufrufer (siehe Input.Consideration). Was diese Stufe zuverlässig
|
||||
// automatisieren kann, ist reine Zeichenketten-Logik: steht ein
|
||||
// Kennzeichnungswort in der Caption, und steht es vor der
|
||||
// "mehr anzeigen"-Kürzung der Plattform.
|
||||
package extract
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/rules"
|
||||
)
|
||||
|
||||
// EngineVersion wird zusammen mit jeder Extraktion gespeichert (siehe
|
||||
// Datenmodell: extraction.model_version) — hochzählen bei jeder
|
||||
// inhaltlichen Änderung an der Erkennungslogik unten.
|
||||
const EngineVersion = "regelbasiert-v1"
|
||||
|
||||
// PromptVersion wird ebenfalls gespeichert (extraction.prompt_version).
|
||||
// Es gibt kein Sprachmodell und keinen Prompt mehr, aber die Spalte
|
||||
// bleibt (keine neue Migration nur für einen Namenswechsel) — der Wert
|
||||
// markiert weiterhin den Stand der Extraktionslogik.
|
||||
const PromptVersion = "v1"
|
||||
|
||||
// disclosureKeywords sind Kennzeichnungshinweise, nach denen in der
|
||||
// Caption gesucht wird — bewusst großzügig (auch rechtlich unzureichende
|
||||
// wie "#ad", siehe rules/OPEN.md/Recherche zu OLG Celle/Kammergericht
|
||||
// Berlin): ob ein Wortlaut rechtlich ausreicht, entscheidet das
|
||||
// Regelwerk anhand von DisclosureWording, nicht diese Erkennung.
|
||||
var disclosureKeywords = []string{
|
||||
"werbung", "anzeige", "bezahlte partnerschaft", "paid partnership",
|
||||
"#ad", "#werbung", "#anzeige", "#sponsored", "#sponsoredby", "#sponsoredpost",
|
||||
}
|
||||
|
||||
// truncationThresholds sind ungefähre Zeichen-Schwellen, ab denen
|
||||
// Instagram/TikTok eine Caption in der Zeitleiste hinter "... mehr" /
|
||||
// "mehr anzeigen" kürzen. Plattformen ändern das ohne Ankündigung — vor
|
||||
// echtem Kundeneinsatz stichprobenartig nachprüfen, ob die Werte noch
|
||||
// stimmen (siehe CLAUDE.md, Offene Punkte).
|
||||
var truncationThresholds = map[string]int{
|
||||
"instagram": 125,
|
||||
"tiktok": 150,
|
||||
}
|
||||
|
||||
const defaultTruncationThreshold = 125
|
||||
|
||||
// Input ist, was Stufe 1 braucht. Platform, Jurisdiction UND
|
||||
// Consideration kommen alle vom Aufrufer — keines davon lässt sich aus
|
||||
// der Caption zuverlässig ableiten oder ist Sache dieser Stufe.
|
||||
type Input struct {
|
||||
Platform string
|
||||
Jurisdiction string
|
||||
Consideration string // "bezahlt" | "sachbezug" | "keine" | "unklar"
|
||||
Caption string
|
||||
}
|
||||
|
||||
// Result ist die Ausgabe von Extract: die für das Regelwerk
|
||||
// aufbereiteten Facts, plus RawJSON — die vollständige, unveränderte
|
||||
// Aufzeichnung dessen, was diese Stufe bestimmt 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
|
||||
}
|
||||
|
||||
// Engine bestimmt die Facts für einen Beitrag über deterministische
|
||||
// Zeichenketten-Logik — kein externer Dienst, keine Netzwerk-Abhängigkeit.
|
||||
type Engine struct{}
|
||||
|
||||
// NewEngine erstellt eine Engine.
|
||||
func NewEngine() *Engine {
|
||||
return &Engine{}
|
||||
}
|
||||
|
||||
// ModelVersion liefert EngineVersion (siehe Datenmodell: extraction.model_version).
|
||||
func (e *Engine) ModelVersion() string { return EngineVersion }
|
||||
|
||||
// Extract bestimmt die Facts für in. Die Gegenleistung wird validiert,
|
||||
// nicht erraten — ein leerer oder ungültiger Wert ist ein Fehler, keine
|
||||
// Lücke, die stillschweigend als "keine" interpretiert wird (das wäre
|
||||
// hier besonders gefährlich: es würde unentdeckte Schleichwerbung
|
||||
// systematisch als unauffällig durchwinken).
|
||||
func (e *Engine) Extract(ctx context.Context, in Input) (Result, error) {
|
||||
if in.Caption == "" {
|
||||
return Result{}, fmt.Errorf("extract: caption ist leer")
|
||||
}
|
||||
consideration, err := parseConsideration(in.Consideration)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("extract: %w", err)
|
||||
}
|
||||
|
||||
present, wording, index := detectDisclosure(in.Caption)
|
||||
beforeCut := present && index < truncationThreshold(in.Platform)
|
||||
|
||||
args := extractionArgs{
|
||||
Consideration: string(consideration),
|
||||
DisclosurePresent: present,
|
||||
DisclosureWording: wording,
|
||||
DisclosureBeforeCut: beforeCut,
|
||||
}
|
||||
raw, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("extract: payload marshal: %w", err)
|
||||
}
|
||||
|
||||
return Result{
|
||||
Facts: rules.Facts{
|
||||
Platform: in.Platform,
|
||||
Jurisdiction: in.Jurisdiction,
|
||||
Consideration: consideration,
|
||||
DisclosurePresent: present,
|
||||
DisclosureWording: wording,
|
||||
DisclosureBeforeCut: beforeCut,
|
||||
},
|
||||
RawJSON: raw,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// detectDisclosure sucht die am frühesten in caption vorkommende
|
||||
// Kennzeichnung aus disclosureKeywords (case-insensitive) und liefert
|
||||
// deren Wortlaut in Original-Schreibweise plus Byte-Index.
|
||||
func detectDisclosure(caption string) (present bool, wording string, index int) {
|
||||
lower := strings.ToLower(caption)
|
||||
bestIdx := -1
|
||||
bestLen := 0
|
||||
for _, kw := range disclosureKeywords {
|
||||
if idx := strings.Index(lower, kw); idx != -1 && (bestIdx == -1 || idx < bestIdx) {
|
||||
bestIdx = idx
|
||||
bestLen = len(kw)
|
||||
}
|
||||
}
|
||||
if bestIdx == -1 {
|
||||
return false, "", -1
|
||||
}
|
||||
return true, caption[bestIdx : bestIdx+bestLen], bestIdx
|
||||
}
|
||||
|
||||
func truncationThreshold(platform string) int {
|
||||
if t, ok := truncationThresholds[platform]; ok {
|
||||
return t
|
||||
}
|
||||
return defaultTruncationThreshold
|
||||
}
|
||||
|
||||
// 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 gespeicherten Payloads.
|
||||
func ParsePayload(payload []byte, platform, jurisdiction string) (rules.Facts, error) {
|
||||
var args extractionArgs
|
||||
if err := json.Unmarshal(payload, &args); err != nil {
|
||||
return rules.Facts{}, fmt.Errorf("extract: payload parse: %w", err)
|
||||
}
|
||||
|
||||
consideration, err := parseConsideration(args.Consideration)
|
||||
if err != nil {
|
||||
return rules.Facts{}, fmt.Errorf("extract: %w", err)
|
||||
}
|
||||
|
||||
return rules.Facts{
|
||||
Platform: platform,
|
||||
Jurisdiction: jurisdiction,
|
||||
Consideration: consideration,
|
||||
DisclosurePresent: args.DisclosurePresent,
|
||||
DisclosureWording: args.DisclosureWording,
|
||||
DisclosureBeforeCut: args.DisclosureBeforeCut,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// extractionArgs ist die gespeicherte Form eines Extraktionsergebnisses.
|
||||
type extractionArgs struct {
|
||||
Consideration string `json:"gegenleistung"`
|
||||
DisclosurePresent bool `json:"kennzeichnung_vorhanden"`
|
||||
DisclosureWording string `json:"kennzeichnung_wortlaut"`
|
||||
DisclosureBeforeCut bool `json:"kennzeichnung_vor_kuerzung"`
|
||||
}
|
||||
|
||||
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("ungültiger gegenleistung-Wert %q — muss vom Einreichenden angegeben werden", s)
|
||||
}
|
||||
}
|
||||
164
internal/extract/engine_test.go
Normal file
164
internal/extract/engine_test.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package extract_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/extract"
|
||||
"github.com/netcell-it/deklarix/internal/rules"
|
||||
)
|
||||
|
||||
func TestExtractRejectsEmptyCaption(t *testing.T) {
|
||||
e := extract.NewEngine()
|
||||
if _, err := e.Extract(context.Background(), extract.Input{Platform: "instagram", Consideration: "bezahlt"}); err == nil {
|
||||
t.Fatal("expected error for empty caption, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRejectsMissingConsideration(t *testing.T) {
|
||||
e := extract.NewEngine()
|
||||
if _, err := e.Extract(context.Background(), extract.Input{Platform: "instagram", Caption: "irgendein Text"}); err == nil {
|
||||
t.Fatal("expected error for missing consideration, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRejectsInvalidConsideration(t *testing.T) {
|
||||
e := extract.NewEngine()
|
||||
_, err := e.Extract(context.Background(), extract.Input{
|
||||
Platform: "instagram", Caption: "Text", Consideration: "vielleicht",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for an out-of-enum consideration value, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDetectsDisclosureKeyword(t *testing.T) {
|
||||
e := extract.NewEngine()
|
||||
result, err := e.Extract(context.Background(), extract.Input{
|
||||
Platform: "instagram", Consideration: "bezahlt",
|
||||
Caption: "Werbung: Schaut euch dieses tolle Produkt an!",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Extract: %v", err)
|
||||
}
|
||||
if !result.Facts.DisclosurePresent {
|
||||
t.Error("expected DisclosurePresent = true")
|
||||
}
|
||||
if result.Facts.DisclosureWording != "Werbung" {
|
||||
t.Errorf("DisclosureWording = %q, want Werbung (original casing preserved)", result.Facts.DisclosureWording)
|
||||
}
|
||||
if !result.Facts.DisclosureBeforeCut {
|
||||
t.Error("expected DisclosureBeforeCut = true (Werbung is at index 0)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractIsCaseInsensitive(t *testing.T) {
|
||||
e := extract.NewEngine()
|
||||
result, err := e.Extract(context.Background(), extract.Input{
|
||||
Platform: "instagram", Consideration: "bezahlt", Caption: "WERBUNG fuer ein Produkt",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Extract: %v", err)
|
||||
}
|
||||
if !result.Facts.DisclosurePresent {
|
||||
t.Fatal("expected case-insensitive match to find the keyword")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDetectsDisclosureAfterTruncationCut(t *testing.T) {
|
||||
e := extract.NewEngine()
|
||||
// 130 Fuellzeichen vor "Werbung" -> jenseits der Instagram-Schwelle (125).
|
||||
padding := strings.Repeat("x", 130)
|
||||
result, err := e.Extract(context.Background(), extract.Input{
|
||||
Platform: "instagram", Consideration: "bezahlt", Caption: padding + " Werbung",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Extract: %v", err)
|
||||
}
|
||||
if !result.Facts.DisclosurePresent {
|
||||
t.Fatal("expected the keyword to still be found even though it's late in the caption")
|
||||
}
|
||||
if result.Facts.DisclosureBeforeCut {
|
||||
t.Error("expected DisclosureBeforeCut = false when the keyword appears after the platform's truncation threshold")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractNoDisclosureFound(t *testing.T) {
|
||||
e := extract.NewEngine()
|
||||
result, err := e.Extract(context.Background(), extract.Input{
|
||||
Platform: "instagram", Consideration: "keine", Caption: "Ein ganz normaler Tag im Park.",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Extract: %v", err)
|
||||
}
|
||||
if result.Facts.DisclosurePresent {
|
||||
t.Error("expected DisclosurePresent = false when no keyword is present")
|
||||
}
|
||||
if result.Facts.DisclosureBeforeCut {
|
||||
t.Error("expected DisclosureBeforeCut = false when there is no disclosure at all")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractPassesThroughUnclearConsideration(t *testing.T) {
|
||||
e := extract.NewEngine()
|
||||
result, err := e.Extract(context.Background(), extract.Input{
|
||||
Platform: "instagram", Consideration: "unklar", Caption: "Text ohne klare Angabe",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Extract: %v", err)
|
||||
}
|
||||
if result.Facts.Consideration != rules.ConsiderationUnclear {
|
||||
t.Fatalf("Consideration = %q, want unklar", result.Facts.Consideration)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDifferentPlatformThresholds(t *testing.T) {
|
||||
e := extract.NewEngine()
|
||||
// 140 Fuellzeichen: unter der TikTok-Schwelle (150), aber ueber der
|
||||
// Instagram-Schwelle (125) -> gleiche Caption, unterschiedliches Ergebnis.
|
||||
padding := strings.Repeat("x", 140)
|
||||
caption := padding + " Werbung"
|
||||
|
||||
igResult, err := e.Extract(context.Background(), extract.Input{Platform: "instagram", Consideration: "bezahlt", Caption: caption})
|
||||
if err != nil {
|
||||
t.Fatalf("Extract (instagram): %v", err)
|
||||
}
|
||||
ttResult, err := e.Extract(context.Background(), extract.Input{Platform: "tiktok", Consideration: "bezahlt", Caption: caption})
|
||||
if err != nil {
|
||||
t.Fatalf("Extract (tiktok): %v", err)
|
||||
}
|
||||
|
||||
if igResult.Facts.DisclosureBeforeCut {
|
||||
t.Error("expected DisclosureBeforeCut = false for instagram at this length")
|
||||
}
|
||||
if !ttResult.Facts.DisclosureBeforeCut {
|
||||
t.Error("expected DisclosureBeforeCut = true for tiktok at this length")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRawJSONRoundTripsThroughParsePayload(t *testing.T) {
|
||||
e := extract.NewEngine()
|
||||
result, err := e.Extract(context.Background(), extract.Input{
|
||||
Platform: "instagram", Jurisdiction: "DE", Consideration: "sachbezug",
|
||||
Caption: "Anzeige: dieses Produkt wurde mir geschenkt",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Extract: %v", err)
|
||||
}
|
||||
|
||||
facts, err := extract.ParsePayload(result.RawJSON, "instagram", "DE")
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePayload: %v", err)
|
||||
}
|
||||
if facts != result.Facts {
|
||||
t.Fatalf("ParsePayload(Extract().RawJSON) = %+v, want %+v", facts, result.Facts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePayloadRejectsInvalidConsideration(t *testing.T) {
|
||||
payload := []byte(`{"gegenleistung":"vielleicht","kennzeichnung_vorhanden":false,"kennzeichnung_wortlaut":"","kennzeichnung_vor_kuerzung":false}`)
|
||||
if _, err := extract.ParsePayload(payload, "tiktok", "DE"); err == nil {
|
||||
t.Fatal("expected error for out-of-enum stored payload, got nil")
|
||||
}
|
||||
}
|
||||
@@ -60,8 +60,9 @@ func (s *Server) handleCheck(w http.ResponseWriter, r *http.Request) {
|
||||
platform := r.FormValue("platform")
|
||||
postType := r.FormValue("post_type")
|
||||
caption := r.FormValue("caption")
|
||||
if platform == "" || postType == "" || caption == "" {
|
||||
http.Error(w, "Plattform, Beitragstyp und Caption sind Pflichtfelder", http.StatusBadRequest)
|
||||
consideration := r.FormValue("consideration")
|
||||
if platform == "" || postType == "" || caption == "" || consideration == "" {
|
||||
http.Error(w, "Plattform, Beitragstyp, Gegenleistung und Caption sind Pflichtfelder", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -69,9 +70,10 @@ func (s *Server) handleCheck(w http.ResponseWriter, r *http.Request) {
|
||||
accountID := currentUser(r).AccountID
|
||||
|
||||
result, err := s.extractor.Extract(ctx, extract.Input{
|
||||
Platform: platform,
|
||||
Jurisdiction: "DE",
|
||||
Caption: caption,
|
||||
Platform: platform,
|
||||
Jurisdiction: "DE",
|
||||
Consideration: consideration,
|
||||
Caption: caption,
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, "Extraktion fehlgeschlagen: "+err.Error(), http.StatusBadGateway)
|
||||
|
||||
@@ -26,8 +26,9 @@ var templatesFS embed.FS
|
||||
var staticFS embed.FS
|
||||
|
||||
// Extractor ist die Schnittstelle, die der Server für Stufe 1 braucht.
|
||||
// *extract.Client erfüllt sie; Tests injizieren einen Fake statt echte
|
||||
// Claude-API-Aufrufe zu machen.
|
||||
// *extract.Engine erfüllt sie (regelbasiert, kein externer Dienst);
|
||||
// Tests injizieren einen Fake, um Facts unabhängig von der echten
|
||||
// Erkennungslogik vorzugeben.
|
||||
type Extractor interface {
|
||||
Extract(ctx context.Context, in extract.Input) (extract.Result, error)
|
||||
ModelVersion() string
|
||||
|
||||
@@ -373,7 +373,10 @@ func getWithCookie(t *testing.T, s *web.Server, cookie *http.Cookie, path string
|
||||
}
|
||||
|
||||
func checkForm(extra ...string) url.Values {
|
||||
v := url.Values{"platform": {"instagram"}, "post_type": {"reel"}, "caption": {"..."}}
|
||||
v := url.Values{
|
||||
"platform": {"instagram"}, "post_type": {"reel"},
|
||||
"consideration": {"bezahlt"}, "caption": {"Werbung: ..."},
|
||||
}
|
||||
for i := 0; i+1 < len(extra); i += 2 {
|
||||
v.Set(extra[i], extra[i+1])
|
||||
}
|
||||
|
||||
@@ -21,6 +21,19 @@
|
||||
<option value="video">Video</option>
|
||||
</select>
|
||||
|
||||
<label for="consideration">Gegenleistung</label>
|
||||
<select id="consideration" name="consideration" required>
|
||||
<option value="">— bitte wählen —</option>
|
||||
<option value="bezahlt">Bezahlt</option>
|
||||
<option value="sachbezug">Sachbezug (Produkt, Einladung, ...)</option>
|
||||
<option value="keine">Keine</option>
|
||||
<option value="unklar">Unklar</option>
|
||||
</select>
|
||||
<p class="hinweis">
|
||||
Das kann die Prüfung nicht aus der Caption erraten — nur wer
|
||||
einreicht, weiß, ob eine Gegenleistung vorlag.
|
||||
</p>
|
||||
|
||||
<label for="caption">Caption</label>
|
||||
<textarea id="caption" name="caption" rows="6" required></textarea>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user