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