Compare commits

...

3 Commits

Author SHA1 Message Date
noroot
77de1627c5 feat: add mobile-first styling matching enconf's CI
Pages were unstyled HTML until now. design/enterprise.css (enconf's
4400-line stylesheet) is tightly coupled to Ant Design/React class
names and a fixed desktop sidebar layout — not usable as-is for
Deklarix's plain server-rendered forms. Instead, internal/web/static/
app.css is a small, purpose-built mobile-first stylesheet that reuses
enconf's actual design tokens (primary blue #1677ff, radius scale,
shadows, Inter) for brand consistency without dragging in the
unrelated layout/framework rules.

Inter is self-hosted (copied from enconf's font files) rather than
pulled from Google Fonts, keeping the "no runtime internet dependency"
property. Only the "latin" subset is included — German umlauts and ß
all live in U+0000-00FF, so the cyrillic/greek/vietnamese subsets
enconf ships aren't needed here.

Inputs/buttons are sized for touch (44px min-height) and use 16px font
size to avoid iOS's auto-zoom-on-focus. Findings are color-coded by
severity (red/amber/green backgrounds with a matching left border).

Verified visually with chromium --headless --screenshot at both mobile
(390px) and desktop (1280px) viewports, including a real WK-001 finding
fetched from the running server and rendered through the actual
stylesheet — not just asserted via HTTP status codes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 17:41:57 +02:00
noroot
374f4ade31 fix: heal missing RULES_DIR in postinst on upgrade
Discovered live: upgrading the test server to v0.2.0 crash-looped with
"rules: read dir: open .: no such file or directory". Its
/etc/deklarix/deklarix.env predates RULES_DIR entirely (created on
first install, before that variable existed) — postinst never
overwrites an existing env file, by design, so the variable was simply
missing rather than set. main.go's relative default "rules" then
resolved against WorkingDirectory=/var/lib/deklarix instead of the
actual install path (/usr/share/deklarix/rules).

postinst now appends RULES_DIR with its packaged default whenever it's
absent, on both fresh installs and upgrades — never overwriting an
existing value. Same healing pattern enconf uses for DB_SSLMODE.
Verified: reinstalling over the broken config fixed it and the service
came back up.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 17:24:41 +02:00
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
20 changed files with 710 additions and 615 deletions

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
# Build output
dist/
dossiers/
*.exe
*.exe~
*.dll

View File

@@ -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,
Ausgabe striktes JSON (Plattform, Gegenleistung, Kennzeichnung
vorhanden/Kanal/Wortlaut/Position, Produktkategorie, Verlinkungen, ...).
Ist die Extraktion unsicher, liefert sie `unklar` — nie eine geratene
Bewertung.
- **Stufe 1 — Extraktion (`internal/extract`, regelbasiert):** Ob eine
Gegenleistung vorlag, kann aus dem Text nicht zuverlässig erraten
werden — ein verschwiegener bezahlter Post sieht textlich identisch
aus wie ein echter organischer Post. Deshalb gibt der/die Einreichende
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
versionierte YAML-Dateien (`rules/`). Jedes Finding referenziert
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
Regelbedingungen/Extraktion, nie in separate Regelsätze pro Plattform.
**Absolute Regel:** Das Modell nennt niemals selbst Normen, Urteile oder
Randnummern. Fundstellen stehen ausschließlich in den kuratierten
YAML-Regeln. Eine halluzinierte Fundstelle ist in diesem Markt ein
Totalschaden.
**Absolute Regel:** Fundstellen stehen ausschließlich in den kuratierten
YAML-Regeln, nie in Freitext oder Extraktionslogik erfunden. Eine
halluzinierte Fundstelle ist in diesem Markt ein 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` |
| PDF | serverseitig erzeugt |
| 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`) |
Kein Docker für die App selbst, kein React, keine Queue. Neue
@@ -94,7 +108,7 @@ Managed Postgres in der EU (DSGVO).
│ └── deklarix/
│ └── main.go # Entry Point, HTTP-Server
├── internal/
│ ├── extract/ # Claude-API-Aufruf, JSON-Schema, Validierung
│ ├── extract/ # regelbasierte Fakten-Erkennung (Keywords, Kürzungs-Position)
│ ├── rules/ # YAML-Loader, Auswertung, Versionierung
│ ├── evidence/ # Hashing, Zeitstempel, Append-only-Log
│ ├── dossier/ # PDF-Erzeugung
@@ -356,8 +370,8 @@ git push origin main
sudo systemctl start deklarix
sudo systemctl status deklarix
# Config: /etc/deklarix/deklarix.env (DATABASE_URL, PORT, ANTHROPIC_API_KEY,
# RULES_DIR, DOSSIER_DIR, TSA_URL)
# Config: /etc/deklarix/deklarix.env (DATABASE_URL, PORT, RULES_DIR,
# DOSSIER_DIR, TSA_URL)
# Logs prüfen
journalctl -u deklarix -f

View File

@@ -29,14 +29,7 @@ func main() {
}
defer db.Close()
apiKey := os.Getenv("ANTHROPIC_API_KEY")
if apiKey == "" {
log.Fatal("ANTHROPIC_API_KEY is required")
}
extractor, err := extract.NewClient(apiKey)
if err != nil {
log.Fatalf("extract client: %v", err)
}
extractor := extract.NewEngine()
rulesDir := os.Getenv("RULES_DIR")
if rulesDir == "" {

View File

@@ -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",
},
},
}

View File

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

View File

@@ -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
View 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)
}
}

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

View File

@@ -60,8 +60,9 @@ func (s *Server) handleCheck(w http.ResponseWriter, r *http.Request) {
platform := r.FormValue("platform")
postType := r.FormValue("post_type")
caption := r.FormValue("caption")
if platform == "" || postType == "" || caption == "" {
http.Error(w, "Plattform, Beitragstyp und Caption sind Pflichtfelder", http.StatusBadRequest)
consideration := r.FormValue("consideration")
if platform == "" || postType == "" || caption == "" || consideration == "" {
http.Error(w, "Plattform, Beitragstyp, Gegenleistung und Caption sind Pflichtfelder", http.StatusBadRequest)
return
}
@@ -69,9 +70,10 @@ func (s *Server) handleCheck(w http.ResponseWriter, r *http.Request) {
accountID := currentUser(r).AccountID
result, err := s.extractor.Extract(ctx, extract.Input{
Platform: platform,
Jurisdiction: "DE",
Caption: caption,
Platform: platform,
Jurisdiction: "DE",
Consideration: consideration,
Caption: caption,
})
if err != nil {
http.Error(w, "Extraktion fehlgeschlagen: "+err.Error(), http.StatusBadGateway)

View File

@@ -26,8 +26,9 @@ var templatesFS embed.FS
var staticFS embed.FS
// Extractor ist die Schnittstelle, die der Server für Stufe 1 braucht.
// *extract.Client erfüllt sie; Tests injizieren einen Fake statt echte
// Claude-API-Aufrufe zu machen.
// *extract.Engine erfüllt sie (regelbasiert, kein externer Dienst);
// Tests injizieren einen Fake, um Facts unabhängig von der echten
// Erkennungslogik vorzugeben.
type Extractor interface {
Extract(ctx context.Context, in extract.Input) (extract.Result, error)
ModelVersion() string

View File

@@ -373,7 +373,10 @@ func getWithCookie(t *testing.T, s *web.Server, cookie *http.Cookie, path string
}
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 {
v.Set(extra[i], extra[i+1])
}

245
internal/web/static/app.css Normal file
View File

@@ -0,0 +1,245 @@
/* Deklarix — eigenes, schlankes Stylesheet auf Basis der Design-Tokens
aus dem enconf Enterprise Light Theme (design/enterprise.css): gleiche
Marke (Primärblau #1677ff, Inter, Radius-Skala), aber ohne dessen
Ant-Design-/Desktop-Sidebar-Layout, das für Deklarix nicht passt.
Mobile-first: Basis-Stile gelten fürs Telefon, @media (min-width)
erweitert für größere Bildschirme. */
@import url('/static/inter.css');
:root {
--branding-primary: #1677ff;
--radius: 6px;
--radius-md: 8px;
--radius-lg: 10px;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
--color-bg: #f8fafc;
--color-text: #334155;
--color-heading: #0f172a;
--color-border: #e2e8f0;
--color-muted: #64748b;
--color-hoch: #b91c1c;
--color-hoch-bg: #fef2f2;
--color-mittel: #b45309;
--color-mittel-bg: #fffbeb;
--color-niedrig: #166534;
--color-niedrig-bg: #f0fdf4;
}
* {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--color-bg);
color: var(--color-text);
font-size: 16px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
h1, h2, h3 {
color: var(--color-heading);
font-weight: 600;
letter-spacing: -0.02em;
margin: 0 0 0.5em;
}
h1 {
font-size: 1.5rem;
}
p {
margin: 0 0 1em;
}
a {
color: var(--branding-primary);
}
/* Container: volle Breite + Innenabstand auf dem Handy, zentriert mit
fester Breite ab Tablet-Größe aufwärts. */
.page {
padding: 16px;
max-width: 640px;
margin: 0 auto;
}
nav {
display: flex;
justify-content: flex-end;
padding: 12px 16px;
}
/* Formulare: großzügige Touch-Ziele (min. 44px Höhe), volle Breite auf
dem Handy. */
form {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 24px;
}
label {
font-weight: 500;
font-size: 0.875rem;
color: var(--color-heading);
margin-top: 12px;
}
input, select, textarea, button {
font: inherit;
font-size: 16px; /* verhindert Auto-Zoom beim Fokussieren auf iOS */
}
input, select, textarea {
padding: 10px 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: #fff;
color: var(--color-text);
min-height: 44px;
width: 100%;
}
textarea {
min-height: 120px;
resize: vertical;
}
input:focus, select:focus, textarea:focus {
outline: 2px solid var(--branding-primary);
outline-offset: 1px;
border-color: var(--branding-primary);
}
button {
margin-top: 16px;
padding: 12px 20px;
min-height: 44px;
border: none;
border-radius: var(--radius);
background: var(--branding-primary);
color: #fff;
font-weight: 500;
cursor: pointer;
box-shadow: var(--shadow-sm);
}
button:hover {
filter: brightness(0.94);
}
button:active {
filter: brightness(0.88);
}
nav button {
margin-top: 0;
background: transparent;
color: var(--color-muted);
border: 1px solid var(--color-border);
box-shadow: none;
min-height: 36px;
padding: 6px 14px;
font-size: 0.875rem;
}
.hinweis {
font-size: 0.8125rem;
color: var(--color-muted);
margin-top: 4px;
}
.disclaimer {
font-size: 0.8125rem;
color: var(--color-muted);
border-top: 1px solid var(--color-border);
padding-top: 12px;
margin-top: 24px;
}
.fehler {
background: var(--color-hoch-bg);
color: var(--color-hoch);
border-radius: var(--radius);
padding: 12px;
font-size: 0.9375rem;
}
.rueckfrage {
background: var(--color-mittel-bg);
color: var(--color-mittel);
border-radius: var(--radius);
padding: 12px;
}
.keine-findings {
background: var(--color-niedrig-bg);
color: var(--color-niedrig);
border-radius: var(--radius);
padding: 12px;
}
.archiviert {
background: var(--color-niedrig-bg);
color: var(--color-niedrig);
border-radius: var(--radius);
padding: 12px;
}
.findings {
list-style: none;
margin: 0 0 16px;
padding: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.finding {
border-radius: var(--radius-md);
padding: 12px 14px;
box-shadow: var(--shadow);
}
.finding p {
margin: 6px 0 0;
font-size: 0.9375rem;
}
.finding-hoch {
background: var(--color-hoch-bg);
border-left: 4px solid var(--color-hoch);
}
.finding-mittel {
background: var(--color-mittel-bg);
border-left: 4px solid var(--color-mittel);
}
.finding-niedrig {
background: var(--color-niedrig-bg);
border-left: 4px solid var(--color-niedrig);
}
/* Ab hier mehr Platz (Tablet/Desktop) — der Container bekommt spürbaren
Rand statt volle Breite, sonst bleibt alles identisch. */
@media (min-width: 640px) {
.page {
padding: 32px 24px;
}
h1 {
font-size: 1.75rem;
}
}

Binary file not shown.

View File

@@ -0,0 +1,13 @@
/* Inter, selbst gehostet (aus enconf übernommen) — nur der "latin"-Subset
(U+0000-00FF u.a.), das deckt deutsche Umlaute und ß bereits ab, ohne
Zeichensätze für Kyrillisch/Griechisch/Vietnamesisch mitzuladen, die
Deklarix nicht braucht. Eine Datei für alle Schriftschnitte (variable
Font), daher font-weight als Bereich. */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url(/static/fonts/inter-latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

View File

@@ -3,6 +3,7 @@
<head>{{template "head" .}}</head>
<body>
{{template "nav" .}}
<div class="page">
<h1>Pre-Publish-Prüfung</h1>
<p>Caption und Plattform eingeben, um auf Kennzeichnungsrisiken zu prüfen.</p>
@@ -21,6 +22,19 @@
<option value="video">Video</option>
</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>
<textarea id="caption" name="caption" rows="6" required></textarea>
@@ -28,6 +42,7 @@
</form>
<div id="ergebnis"></div>
</div>
</body>
</html>
{{end}}

View File

@@ -2,6 +2,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} — Deklarix</title>
<link rel="stylesheet" href="/static/app.css">
<script src="/static/htmx.min.js"></script>
{{end}}

View File

@@ -2,6 +2,7 @@
<html lang="de">
<head>{{template "head" .}}</head>
<body>
<div class="page">
<h1>Anmelden</h1>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
<form method="post" action="/login">
@@ -14,6 +15,7 @@
<button type="submit">Anmelden</button>
</form>
<p><a href="/register">Noch kein Konto? Registrieren</a></p>
</div>
</body>
</html>
{{end}}

View File

@@ -2,6 +2,7 @@
<html lang="de">
<head>{{template "head" .}}</head>
<body>
<div class="page">
<h1>Registrieren</h1>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
<form method="post" action="/register">
@@ -25,6 +26,7 @@
<button type="submit">Konto anlegen</button>
</form>
<p><a href="/login">Schon ein Konto? Anmelden</a></p>
</div>
</body>
</html>
{{end}}

View File

@@ -42,22 +42,34 @@ case "$1" in
chown "root:$SERVICE_USER" "$CONFIG_DIR/deklarix.env"
fi
# ─── Healing: fehlende Variablen aus älteren Installationen ───
# deklarix.env wird bei Upgrades nie überschrieben (Secrets/
# Anpassungen bleiben erhalten) — Variablen, die erst nach einer
# Erstinstallation dazugekommen sind, fehlen dort sonst schlicht
# und lassen den Dienst mit einem für den Admin überraschenden
# Fehler abbrechen (z. B. RULES_DIR fehlt -> main.go sucht Regeln
# im falschen Verzeichnis, obwohl DATABASE_URL längst gesetzt
# war). Bekannte Variablen mit unkritischem Default werden hier
# ergänzt, falls sie fehlen — nie überschrieben, nur angehängt.
if [ -f "$CONFIG_DIR/deklarix.env" ]; then
grep -q '^RULES_DIR=' "$CONFIG_DIR/deklarix.env" || \
echo "RULES_DIR=/usr/share/deklarix/rules" >> "$CONFIG_DIR/deklarix.env"
fi
systemctl daemon-reload 2>/dev/null || true
systemctl enable deklarix.service >/dev/null 2>&1 || true
# Nicht blind starten — ohne gesetzte DATABASE_URL/ANTHROPIC_API_KEY
# würde der Dienst nur in eine Restart-Schleife laufen (main.go
# bricht sonst bewusst mit log.Fatal ab, siehe CLAUDE.md "keine
# stillen Fallbacks"). Die Vorlage liefert beide auskommentiert aus —
# ein Treffer hier bedeutet also wirklich "vom Admin gesetzt", nicht
# den Platzhalter.
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
# Nicht blind starten — ohne gesetzte DATABASE_URL würde der Dienst
# nur in eine Restart-Schleife laufen (main.go bricht sonst bewusst
# mit log.Fatal ab, siehe CLAUDE.md "keine stillen Fallbacks"). Die
# Vorlage liefert sie auskommentiert aus — ein Treffer hier bedeutet
# also wirklich "vom Admin gesetzt", nicht den Platzhalter.
if grep -qE '^DATABASE_URL=.+' "$CONFIG_DIR/deklarix.env" 2>/dev/null; then
systemctl restart deklarix.service
else
echo ""
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 ""
fi

View File

@@ -18,10 +18,8 @@ DOSSIER_DIR=/var/lib/deklarix/dossiers
# Kundeneinsatz auf einen eIDAS-qualifizierten Dienst umstellen.
#TSA_URL=https://freetsa.org/tsr
# Pflicht — der Dienst startet nicht ohne gültige DATABASE_URL und
# ANTHROPIC_API_KEY. Beide auskommentiert lassen, bis echte Werte
# eingetragen sind: postinst prüft genau diese beiden Zeilen, um den
# Dienst nicht blind in eine Restart-Schleife gegen Platzhalter laufen
# zu lassen.
# Pflicht — der Dienst startet nicht ohne gültige DATABASE_URL.
# Auskommentiert lassen, bis ein echter Wert eingetragen ist: postinst
# prüft genau diese Zeile, um den Dienst nicht blind in eine Restart-
# Schleife gegen einen Platzhalter laufen zu lassen.
#DATABASE_URL=postgres://user:password@host:5432/deklarix?sslmode=require
#ANTHROPIC_API_KEY=