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
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
|||||||
# Build output
|
# Build output
|
||||||
dist/
|
dist/
|
||||||
|
dossiers/
|
||||||
*.exe
|
*.exe
|
||||||
*.exe~
|
*.exe~
|
||||||
*.dll
|
*.dll
|
||||||
|
|||||||
44
CLAUDE.md
44
CLAUDE.md
@@ -35,15 +35,22 @@ Login als Selbstzweck.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Kernprinzip: das Modell entscheidet nicht
|
## Kernprinzip: Fakten trennen von Urteil
|
||||||
|
|
||||||
**Das LLM extrahiert Fakten. Das Regelwerk in Go fällt das Urteil.**
|
**Stufe 1 bestimmt Fakten (deterministisch, kein LLM). Das Regelwerk in
|
||||||
|
Go fällt das Urteil.**
|
||||||
|
|
||||||
- **Stufe 1 — Extraktion (Claude API):** Eingabe Caption plus Bild/Video,
|
- **Stufe 1 — Extraktion (`internal/extract`, regelbasiert):** Ob eine
|
||||||
Ausgabe striktes JSON (Plattform, Gegenleistung, Kennzeichnung
|
Gegenleistung vorlag, kann aus dem Text nicht zuverlässig erraten
|
||||||
vorhanden/Kanal/Wortlaut/Position, Produktkategorie, Verlinkungen, ...).
|
werden — ein verschwiegener bezahlter Post sieht textlich identisch
|
||||||
Ist die Extraktion unsicher, liefert sie `unklar` — nie eine geratene
|
aus wie ein echter organischer Post. Deshalb gibt der/die Einreichende
|
||||||
Bewertung.
|
die Gegenleistung selbst an (Formularfeld). Was sich objektiv aus der
|
||||||
|
Caption bestimmen lässt, wird per Zeichenketten-Logik erkannt: ob ein
|
||||||
|
Kennzeichnungswort vorkommt, welcher Wortlaut, und ob es vor der
|
||||||
|
plattformspezifischen "mehr anzeigen"-Kürzung steht. Kein externer
|
||||||
|
Dienst, keine API-Kosten, kein Sprachmodell — daher auch keine
|
||||||
|
Interpretation kreativer/impliziter Formulierungen; was nicht über ein
|
||||||
|
bekanntes Kennzeichnungswort läuft, wird nicht erkannt.
|
||||||
- **Stufe 2 — Bewertung (Go, deterministisch):** Regelwerk als
|
- **Stufe 2 — Bewertung (Go, deterministisch):** Regelwerk als
|
||||||
versionierte YAML-Dateien (`rules/`). Jedes Finding referenziert
|
versionierte YAML-Dateien (`rules/`). Jedes Finding referenziert
|
||||||
Regel-ID und Version (z. B. `WK-004 v3`). Regeln sind
|
Regel-ID und Version (z. B. `WK-004 v3`). Regeln sind
|
||||||
@@ -51,10 +58,17 @@ Login als Selbstzweck.
|
|||||||
Darstellungsdetails unterscheiden sich; die gehören in
|
Darstellungsdetails unterscheiden sich; die gehören in
|
||||||
Regelbedingungen/Extraktion, nie in separate Regelsätze pro Plattform.
|
Regelbedingungen/Extraktion, nie in separate Regelsätze pro Plattform.
|
||||||
|
|
||||||
**Absolute Regel:** Das Modell nennt niemals selbst Normen, Urteile oder
|
**Absolute Regel:** Fundstellen stehen ausschließlich in den kuratierten
|
||||||
Randnummern. Fundstellen stehen ausschließlich in den kuratierten
|
YAML-Regeln, nie in Freitext oder Extraktionslogik erfunden. Eine
|
||||||
YAML-Regeln. Eine halluzinierte Fundstelle ist in diesem Markt ein
|
halluzinierte Fundstelle ist in diesem Markt ein Totalschaden.
|
||||||
Totalschaden.
|
|
||||||
|
**Historie:** Ursprünglich war Stufe 1 als Claude-API-Aufruf geplant
|
||||||
|
(Kennzeichnung *und* Gegenleistung vom Modell erraten). Das wurde
|
||||||
|
bewusst verworfen — der Nutzer will keine laufende Anthropic-Abhängigkeit
|
||||||
|
für Deklarix selbst (getrennt von Claude Code, mit dem an Deklarix
|
||||||
|
programmiert wird). Die Idee "LLM sieht Kontext-Hinweise wie
|
||||||
|
Rabattcodes/Affiliate-Links, die reines Keyword-Matching übersieht"
|
||||||
|
bleibt ein späterer Ausbaupunkt, kein aktueller Blocker.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -69,7 +83,7 @@ Bewusst langweilig, ein Binary, lokal lauffähig:
|
|||||||
| Datenbank | PostgreSQL über `pgx`, Migrationen über `golang-migrate` |
|
| Datenbank | PostgreSQL über `pgx`, Migrationen über `golang-migrate` |
|
||||||
| PDF | serverseitig erzeugt |
|
| PDF | serverseitig erzeugt |
|
||||||
| Zeitstempel | RFC-3161 von einer öffentlichen TSA |
|
| Zeitstempel | RFC-3161 von einer öffentlichen TSA |
|
||||||
| Extraktion | Claude API (nur Fakten-Extraktion, siehe oben) |
|
| Extraktion | regelbasiert, `internal/extract`, keine externe Abhängigkeit |
|
||||||
| Design | `design/enterprise.css` (Enterprise Light Theme, enconf-Pattern, Primärfarbe `#1677ff`) |
|
| Design | `design/enterprise.css` (Enterprise Light Theme, enconf-Pattern, Primärfarbe `#1677ff`) |
|
||||||
|
|
||||||
Kein Docker für die App selbst, kein React, keine Queue. Neue
|
Kein Docker für die App selbst, kein React, keine Queue. Neue
|
||||||
@@ -94,7 +108,7 @@ Managed Postgres in der EU (DSGVO).
|
|||||||
│ └── deklarix/
|
│ └── deklarix/
|
||||||
│ └── main.go # Entry Point, HTTP-Server
|
│ └── main.go # Entry Point, HTTP-Server
|
||||||
├── internal/
|
├── internal/
|
||||||
│ ├── extract/ # Claude-API-Aufruf, JSON-Schema, Validierung
|
│ ├── extract/ # regelbasierte Fakten-Erkennung (Keywords, Kürzungs-Position)
|
||||||
│ ├── rules/ # YAML-Loader, Auswertung, Versionierung
|
│ ├── rules/ # YAML-Loader, Auswertung, Versionierung
|
||||||
│ ├── evidence/ # Hashing, Zeitstempel, Append-only-Log
|
│ ├── evidence/ # Hashing, Zeitstempel, Append-only-Log
|
||||||
│ ├── dossier/ # PDF-Erzeugung
|
│ ├── dossier/ # PDF-Erzeugung
|
||||||
@@ -356,8 +370,8 @@ git push origin main
|
|||||||
sudo systemctl start deklarix
|
sudo systemctl start deklarix
|
||||||
sudo systemctl status deklarix
|
sudo systemctl status deklarix
|
||||||
|
|
||||||
# Config: /etc/deklarix/deklarix.env (DATABASE_URL, PORT, ANTHROPIC_API_KEY,
|
# Config: /etc/deklarix/deklarix.env (DATABASE_URL, PORT, RULES_DIR,
|
||||||
# RULES_DIR, DOSSIER_DIR, TSA_URL)
|
# DOSSIER_DIR, TSA_URL)
|
||||||
|
|
||||||
# Logs prüfen
|
# Logs prüfen
|
||||||
journalctl -u deklarix -f
|
journalctl -u deklarix -f
|
||||||
|
|||||||
@@ -29,14 +29,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
apiKey := os.Getenv("ANTHROPIC_API_KEY")
|
extractor := extract.NewEngine()
|
||||||
if apiKey == "" {
|
|
||||||
log.Fatal("ANTHROPIC_API_KEY is required")
|
|
||||||
}
|
|
||||||
extractor, err := extract.NewClient(apiKey)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("extract client: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
rulesDir := os.Getenv("RULES_DIR")
|
rulesDir := os.Getenv("RULES_DIR")
|
||||||
if rulesDir == "" {
|
if rulesDir == "" {
|
||||||
|
|||||||
@@ -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")
|
platform := r.FormValue("platform")
|
||||||
postType := r.FormValue("post_type")
|
postType := r.FormValue("post_type")
|
||||||
caption := r.FormValue("caption")
|
caption := r.FormValue("caption")
|
||||||
if platform == "" || postType == "" || caption == "" {
|
consideration := r.FormValue("consideration")
|
||||||
http.Error(w, "Plattform, Beitragstyp und Caption sind Pflichtfelder", http.StatusBadRequest)
|
if platform == "" || postType == "" || caption == "" || consideration == "" {
|
||||||
|
http.Error(w, "Plattform, Beitragstyp, Gegenleistung und Caption sind Pflichtfelder", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +72,7 @@ func (s *Server) handleCheck(w http.ResponseWriter, r *http.Request) {
|
|||||||
result, err := s.extractor.Extract(ctx, extract.Input{
|
result, err := s.extractor.Extract(ctx, extract.Input{
|
||||||
Platform: platform,
|
Platform: platform,
|
||||||
Jurisdiction: "DE",
|
Jurisdiction: "DE",
|
||||||
|
Consideration: consideration,
|
||||||
Caption: caption,
|
Caption: caption,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -26,8 +26,9 @@ var templatesFS embed.FS
|
|||||||
var staticFS embed.FS
|
var staticFS embed.FS
|
||||||
|
|
||||||
// Extractor ist die Schnittstelle, die der Server für Stufe 1 braucht.
|
// Extractor ist die Schnittstelle, die der Server für Stufe 1 braucht.
|
||||||
// *extract.Client erfüllt sie; Tests injizieren einen Fake statt echte
|
// *extract.Engine erfüllt sie (regelbasiert, kein externer Dienst);
|
||||||
// Claude-API-Aufrufe zu machen.
|
// Tests injizieren einen Fake, um Facts unabhängig von der echten
|
||||||
|
// Erkennungslogik vorzugeben.
|
||||||
type Extractor interface {
|
type Extractor interface {
|
||||||
Extract(ctx context.Context, in extract.Input) (extract.Result, error)
|
Extract(ctx context.Context, in extract.Input) (extract.Result, error)
|
||||||
ModelVersion() string
|
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 {
|
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 {
|
for i := 0; i+1 < len(extra); i += 2 {
|
||||||
v.Set(extra[i], extra[i+1])
|
v.Set(extra[i], extra[i+1])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,19 @@
|
|||||||
<option value="video">Video</option>
|
<option value="video">Video</option>
|
||||||
</select>
|
</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>
|
<label for="caption">Caption</label>
|
||||||
<textarea id="caption" name="caption" rows="6" required></textarea>
|
<textarea id="caption" name="caption" rows="6" required></textarea>
|
||||||
|
|
||||||
|
|||||||
@@ -45,19 +45,17 @@ case "$1" in
|
|||||||
systemctl daemon-reload 2>/dev/null || true
|
systemctl daemon-reload 2>/dev/null || true
|
||||||
systemctl enable deklarix.service >/dev/null 2>&1 || true
|
systemctl enable deklarix.service >/dev/null 2>&1 || true
|
||||||
|
|
||||||
# Nicht blind starten — ohne gesetzte DATABASE_URL/ANTHROPIC_API_KEY
|
# Nicht blind starten — ohne gesetzte DATABASE_URL würde der Dienst
|
||||||
# würde der Dienst nur in eine Restart-Schleife laufen (main.go
|
# nur in eine Restart-Schleife laufen (main.go bricht sonst bewusst
|
||||||
# bricht sonst bewusst mit log.Fatal ab, siehe CLAUDE.md "keine
|
# mit log.Fatal ab, siehe CLAUDE.md "keine stillen Fallbacks"). Die
|
||||||
# stillen Fallbacks"). Die Vorlage liefert beide auskommentiert aus —
|
# Vorlage liefert sie auskommentiert aus — ein Treffer hier bedeutet
|
||||||
# ein Treffer hier bedeutet also wirklich "vom Admin gesetzt", nicht
|
# also wirklich "vom Admin gesetzt", nicht den Platzhalter.
|
||||||
# den Platzhalter.
|
if grep -qE '^DATABASE_URL=.+' "$CONFIG_DIR/deklarix.env" 2>/dev/null; then
|
||||||
if grep -qE '^DATABASE_URL=.+' "$CONFIG_DIR/deklarix.env" 2>/dev/null \
|
|
||||||
&& grep -qE '^ANTHROPIC_API_KEY=.+' "$CONFIG_DIR/deklarix.env" 2>/dev/null; then
|
|
||||||
systemctl restart deklarix.service
|
systemctl restart deklarix.service
|
||||||
else
|
else
|
||||||
echo ""
|
echo ""
|
||||||
echo " → Deklarix installiert, aber noch nicht gestartet."
|
echo " → Deklarix installiert, aber noch nicht gestartet."
|
||||||
echo " DATABASE_URL und ANTHROPIC_API_KEY in $CONFIG_DIR/deklarix.env setzen, dann:"
|
echo " DATABASE_URL in $CONFIG_DIR/deklarix.env setzen, dann:"
|
||||||
echo " systemctl start deklarix"
|
echo " systemctl start deklarix"
|
||||||
echo ""
|
echo ""
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -18,10 +18,8 @@ DOSSIER_DIR=/var/lib/deklarix/dossiers
|
|||||||
# Kundeneinsatz auf einen eIDAS-qualifizierten Dienst umstellen.
|
# Kundeneinsatz auf einen eIDAS-qualifizierten Dienst umstellen.
|
||||||
#TSA_URL=https://freetsa.org/tsr
|
#TSA_URL=https://freetsa.org/tsr
|
||||||
|
|
||||||
# Pflicht — der Dienst startet nicht ohne gültige DATABASE_URL und
|
# Pflicht — der Dienst startet nicht ohne gültige DATABASE_URL.
|
||||||
# ANTHROPIC_API_KEY. Beide auskommentiert lassen, bis echte Werte
|
# Auskommentiert lassen, bis ein echter Wert eingetragen ist: postinst
|
||||||
# eingetragen sind: postinst prüft genau diese beiden Zeilen, um den
|
# prüft genau diese Zeile, um den Dienst nicht blind in eine Restart-
|
||||||
# Dienst nicht blind in eine Restart-Schleife gegen Platzhalter laufen
|
# Schleife gegen einen Platzhalter laufen zu lassen.
|
||||||
# zu lassen.
|
|
||||||
#DATABASE_URL=postgres://user:password@host:5432/deklarix?sslmode=require
|
#DATABASE_URL=postgres://user:password@host:5432/deklarix?sslmode=require
|
||||||
#ANTHROPIC_API_KEY=
|
|
||||||
|
|||||||
Reference in New Issue
Block a user