From 34b1d8d2a6a3fa360a1a39326de81c53136d59d1 Mon Sep 17 00:00:00 2001 From: noroot Date: Thu, 27 Aug 2026 17:15:39 +0200 Subject: [PATCH] feat: replace Claude-based extraction with a rule-based engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 1 + CLAUDE.md | 44 ++-- cmd/deklarix/main.go | 9 +- internal/extract/api.go | 111 ---------- internal/extract/client.go | 233 -------------------- internal/extract/client_test.go | 225 ------------------- internal/extract/engine.go | 198 +++++++++++++++++ internal/extract/engine_test.go | 164 ++++++++++++++ internal/web/handlers.go | 12 +- internal/web/server.go | 5 +- internal/web/server_test.go | 5 +- internal/web/templates/index.html | 13 ++ packaging/DEBIAN/postinst | 16 +- packaging/etc/deklarix/deklarix.env.example | 10 +- 14 files changed, 431 insertions(+), 615 deletions(-) delete mode 100644 internal/extract/api.go delete mode 100644 internal/extract/client.go delete mode 100644 internal/extract/client_test.go create mode 100644 internal/extract/engine.go create mode 100644 internal/extract/engine_test.go diff --git a/.gitignore b/.gitignore index 7644bf2..bad4be9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Build output dist/ +dossiers/ *.exe *.exe~ *.dll diff --git a/CLAUDE.md b/CLAUDE.md index 8c4b381..15c90e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/cmd/deklarix/main.go b/cmd/deklarix/main.go index 40ea0aa..f5e8fd6 100644 --- a/cmd/deklarix/main.go +++ b/cmd/deklarix/main.go @@ -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 == "" { diff --git a/internal/extract/api.go b/internal/extract/api.go deleted file mode 100644 index 2327a8c..0000000 --- a/internal/extract/api.go +++ /dev/null @@ -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", - }, - }, -} diff --git a/internal/extract/client.go b/internal/extract/client.go deleted file mode 100644 index 802ebc0..0000000 --- a/internal/extract/client.go +++ /dev/null @@ -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) - } -} diff --git a/internal/extract/client_test.go b/internal/extract/client_test.go deleted file mode 100644 index ad3dc60..0000000 --- a/internal/extract/client_test.go +++ /dev/null @@ -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) - } -} diff --git a/internal/extract/engine.go b/internal/extract/engine.go new file mode 100644 index 0000000..77f282e --- /dev/null +++ b/internal/extract/engine.go @@ -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) + } +} diff --git a/internal/extract/engine_test.go b/internal/extract/engine_test.go new file mode 100644 index 0000000..38c6954 --- /dev/null +++ b/internal/extract/engine_test.go @@ -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") + } +} diff --git a/internal/web/handlers.go b/internal/web/handlers.go index b24b433..af1ba67 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -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) diff --git a/internal/web/server.go b/internal/web/server.go index d4ac685..45eedb5 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -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 diff --git a/internal/web/server_test.go b/internal/web/server_test.go index fc2cbe2..be2749e 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -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]) } diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html index 27908de..e2c711b 100644 --- a/internal/web/templates/index.html +++ b/internal/web/templates/index.html @@ -21,6 +21,19 @@ + + +

+ Das kann die Prüfung nicht aus der Caption erraten — nur wer + einreicht, weiß, ob eine Gegenleistung vorlag. +

+ diff --git a/packaging/DEBIAN/postinst b/packaging/DEBIAN/postinst index bc52011..1499ada 100755 --- a/packaging/DEBIAN/postinst +++ b/packaging/DEBIAN/postinst @@ -45,19 +45,17 @@ case "$1" in 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 diff --git a/packaging/etc/deklarix/deklarix.env.example b/packaging/etc/deklarix/deklarix.env.example index 590daaf..793109b 100644 --- a/packaging/etc/deklarix/deklarix.env.example +++ b/packaging/etc/deklarix/deklarix.env.example @@ -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=