Files
deklarix/internal/web/server.go
noroot 34b1d8d2a6 feat: replace Claude-based extraction with a rule-based engine
Deklarix itself no longer depends on the Anthropic API — that was a
separate API key/billing relationship from Claude Code (used to develop
Deklarix), which the user did not intend to take on for the product
itself.

Consideration (Gegenleistung) is no longer guessed from text — it's a
required form field now, since only the submitter actually knows
whether a business relationship existed. A keyword-only system can't
tell a covertly-paid post from a genuinely organic one; they read
identically. What internal/extract *can* still determine reliably and
deterministically from the caption: whether a disclosure keyword is
present (werbung, anzeige, bezahlte partnerschaft, paid partnership,
#ad, #werbung, #anzeige, #sponsored, #sponsoredby, #sponsoredpost —
case-insensitive), its exact original-case wording, and whether it sits
before the platform's "mehr anzeigen" truncation point (~125 chars
Instagram, ~150 TikTok — rough estimates, platforms change these without
notice, verify before real customer use).

internal/extract's Anthropic HTTP client and tool-use schema are gone
(client.go/api.go deleted), replaced by engine.go — a stateless Engine
with no network calls. extract.Result/ParsePayload keep the exact same
JSON shape as before (gegenleistung/kennzeichnung_vorhanden/
kennzeichnung_wortlaut/kennzeichnung_vor_kuerzung), so internal/store and
internal/dossier needed no changes at all — only extract itself, the web
form/handler (new consideration field), and main.go (no more
ANTHROPIC_API_KEY requirement) changed.

Trade-off the user was told and accepted: without an LLM, the system can
no longer independently catch undisclosed paid content that carries no
recognizable keyword at all — that now rests on the submitter's honesty.
Creative or implicit disclosure phrasing outside the keyword list also
won't be recognized.

Verified against a real running instance with zero API keys configured:
register -> check (real rule engine, correctly triggered WK-004 for a
disclosure placed 130 characters in, past the Instagram threshold) ->
archive -> PDF dossier download, all against real Postgres.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 17:15:39 +02:00

111 lines
4.5 KiB
Go

// Package web ist die HTTP-Schicht: Routing, Templates, Handler.
// Bewusst html/template + htmx, kein Frontend-Build (siehe CLAUDE.md,
// Stack). Jede Submission gehört einem Account (Mandant); Auth-Cookies
// binden einen Request an einen angemeldeten Nutzer und damit an dessen
// Account — siehe middleware.go.
package web
import (
"context"
"embed"
"fmt"
"html/template"
"net/http"
"time"
"github.com/netcell-it/deklarix/internal/evidence"
"github.com/netcell-it/deklarix/internal/extract"
"github.com/netcell-it/deklarix/internal/rules"
"github.com/netcell-it/deklarix/internal/store"
)
//go:embed templates/*.html
var templatesFS embed.FS
//go:embed static/*
var staticFS embed.FS
// Extractor ist die Schnittstelle, die der Server für Stufe 1 braucht.
// *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
}
// Store ist die Schnittstelle, die der Server zur Persistenz braucht —
// bewusst schmal (nur was diese Handler tatsächlich nutzen), nicht der
// komplette *store.Store. *store.Store erfüllt sie; Tests injizieren
// einen Fake statt eine echte Datenbank zu brauchen.
type Store interface {
CreateSubmission(ctx context.Context, accountID, platform, postType, caption string) (store.Submission, error)
GetSubmission(ctx context.Context, id string) (store.Submission, error)
SetSubmissionStatus(ctx context.Context, id, status string) error
CreateExtraction(ctx context.Context, submissionID string, payload []byte, modelVersion, promptVersion string) (store.Extraction, error)
GetLatestExtraction(ctx context.Context, submissionID string) (store.Extraction, error)
CreateFinding(ctx context.Context, submissionID string, extractionID *string, ruleID string, ruleVersion int, severity, title, fix string, sources []string) (store.Finding, error)
ListCurrentFindings(ctx context.Context, submissionID string) ([]store.Finding, error)
CreateEvidencePackage(ctx context.Context, submissionID, dossierPath, sha256Hex string, timestampToken []byte) (store.EvidencePackage, error)
GetLatestEvidencePackage(ctx context.Context, submissionID string) (store.EvidencePackage, error)
CreateAccount(ctx context.Context, name string) (store.Account, error)
CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (store.User, error)
GetUserByEmail(ctx context.Context, email string) (store.User, error)
GetUser(ctx context.Context, id string) (store.User, error)
CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (store.Session, error)
GetSession(ctx context.Context, token string) (store.Session, error)
DeleteSession(ctx context.Context, token string) error
}
// Server bündelt Routing und Abhängigkeiten der Web-Schicht.
type Server struct {
mux *http.ServeMux
extractor Extractor
ruleSet []rules.Rule
store Store
timestamper evidence.Timestamper
dossierDir string
templates *template.Template
}
// NewServer erstellt den Server. ruleSet kommt von rules.Load und wird
// einmal beim Start geladen, nicht pro Request. dossierDir ist das
// Verzeichnis, in das erzeugte Nachweis-Dossiers geschrieben werden.
func NewServer(extractor Extractor, ruleSet []rules.Rule, st Store, timestamper evidence.Timestamper, dossierDir string) (*Server, error) {
tmpl, err := template.ParseFS(templatesFS, "templates/*.html")
if err != nil {
return nil, fmt.Errorf("web: templates parsen: %w", err)
}
s := &Server{
extractor: extractor,
ruleSet: ruleSet,
store: st,
timestamper: timestamper,
dossierDir: dossierDir,
templates: tmpl,
}
mux := http.NewServeMux()
mux.HandleFunc("GET /health", s.handleHealth)
mux.HandleFunc("GET /register", s.handleRegisterForm)
mux.HandleFunc("POST /register", s.handleRegister)
mux.HandleFunc("GET /login", s.handleLoginForm)
mux.HandleFunc("POST /login", s.handleLogin)
mux.HandleFunc("POST /logout", s.handleLogout)
mux.HandleFunc("GET /{$}", s.requirePage(s.handleIndex))
mux.HandleFunc("POST /pruefen", s.requireAPI(s.handleCheck))
mux.HandleFunc("POST /veroeffentlichen", s.requireAPI(s.handleArchive))
mux.HandleFunc("GET /dossier/{id}", s.requireAPI(s.handleDossierDownload))
mux.Handle("GET /static/", http.FileServerFS(staticFS))
s.mux = mux
return s, nil
}
// ServeHTTP macht Server zu einem http.Handler.
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.mux.ServeHTTP(w, r)
}