Files
deklarix/internal/extract/engine_test.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

165 lines
5.5 KiB
Go

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