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>
199 lines
7.2 KiB
Go
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)
|
|
}
|
|
}
|