Files
deklarix/internal/extract/engine.go
noroot 34b1d8d2a6 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>
2026-08-27 17:15:39 +02:00

199 lines
7.2 KiB
Go

// 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)
}
}