Files
deklarix/internal/web/handlers.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

275 lines
9.0 KiB
Go

package web
import (
"encoding/hex"
"fmt"
"net/http"
"os"
"path/filepath"
"time"
"github.com/netcell-it/deklarix/internal/dossier"
"github.com/netcell-it/deklarix/internal/evidence"
"github.com/netcell-it/deklarix/internal/extract"
"github.com/netcell-it/deklarix/internal/rules"
)
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"ok":true}`)
}
type indexData struct {
Title string
}
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
if err := s.templates.ExecuteTemplate(w, "index", indexData{Title: "Pre-Publish-Prüfung"}); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
type findingView struct {
RuleID string
Version int
Severity string
Title string
Fix string
Sources []string
}
type resultData struct {
SubmissionID string
NeedsClarification bool
CanArchive bool
Findings []findingView
}
// handleCheck führt die Pre-Publish-Prüfung aus: Extraktion (Stufe 1),
// Regelauswertung (Stufe 2), und speichert Submission + Extraction +
// Findings. Die eigentliche Archivierung (Hash, Zeitstempel, Dossier)
// passiert erst in handleArchive, wenn der Beitrag tatsächlich
// veröffentlicht wurde — das sind unterschiedliche Zeitpunkte im
// Lebenszyklus (siehe CLAUDE.md, Funktion 1 vs. 2).
func (s *Server) handleCheck(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
return
}
platform := r.FormValue("platform")
postType := r.FormValue("post_type")
caption := r.FormValue("caption")
consideration := r.FormValue("consideration")
if platform == "" || postType == "" || caption == "" || consideration == "" {
http.Error(w, "Plattform, Beitragstyp, Gegenleistung und Caption sind Pflichtfelder", http.StatusBadRequest)
return
}
ctx := r.Context()
accountID := currentUser(r).AccountID
result, err := s.extractor.Extract(ctx, extract.Input{
Platform: platform,
Jurisdiction: "DE",
Consideration: consideration,
Caption: caption,
})
if err != nil {
http.Error(w, "Extraktion fehlgeschlagen: "+err.Error(), http.StatusBadGateway)
return
}
sub, err := s.store.CreateSubmission(ctx, accountID, platform, postType, caption)
if err != nil {
http.Error(w, "Beitrag konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
return
}
ext, err := s.store.CreateExtraction(ctx, sub.ID, result.RawJSON, s.extractor.ModelVersion(), extract.PromptVersion)
if err != nil {
http.Error(w, "Extraktion konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
return
}
findings, needsClarification := rules.Evaluate(s.ruleSet, result.Facts)
data := resultData{SubmissionID: sub.ID, NeedsClarification: needsClarification}
if !needsClarification {
extractionID := ext.ID
for _, f := range findings {
if _, err := s.store.CreateFinding(ctx, sub.ID, &extractionID, f.RuleID, f.RuleVersion, string(f.Severity), f.Title, f.Fix, f.Sources); err != nil {
http.Error(w, "Finding konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
return
}
data.Findings = append(data.Findings, findingView{
RuleID: f.RuleID, Version: f.RuleVersion, Severity: string(f.Severity),
Title: f.Title, Fix: f.Fix, Sources: f.Sources,
})
}
if err := s.store.SetSubmissionStatus(ctx, sub.ID, "checked"); err != nil {
http.Error(w, "Status konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError)
return
}
data.CanArchive = true
}
if err := s.templates.ExecuteTemplate(w, "result", data); err != nil {
http.Error(w, "Ergebnis konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
type archivedData struct {
SubmissionID string
DossierURL string
TimestampedAt string
}
// handleArchive markiert einen geprüften Beitrag als veröffentlicht und
// archiviert ihn: Hash der Metadaten, RFC-3161-Zeitstempel, PDF-Dossier.
// Setzt eine vorherige Prüfung voraus (Submission + Extraction +
// Findings müssen bereits existieren).
func (s *Server) handleArchive(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
return
}
submissionID := r.FormValue("submission_id")
if submissionID == "" {
http.Error(w, "submission_id ist Pflicht", http.StatusBadRequest)
return
}
ctx := r.Context()
sub, err := s.store.GetSubmission(ctx, submissionID)
if err != nil {
http.Error(w, "Beitrag nicht gefunden: "+err.Error(), http.StatusNotFound)
return
}
// Mandantentrennung: ein Beitrag eines anderen Accounts wird wie ein
// nicht existierender behandelt, nicht mit einer 403 bestätigt —
// eine 403 würde einem anderen Mandanten verraten, dass die ID
// existiert.
if sub.AccountID != currentUser(r).AccountID {
http.Error(w, "Beitrag nicht gefunden", http.StatusNotFound)
return
}
ext, err := s.store.GetLatestExtraction(ctx, submissionID)
if err != nil {
http.Error(w, "Extraktion nicht gefunden: "+err.Error(), http.StatusNotFound)
return
}
facts, err := extract.ParsePayload(ext.Payload, sub.Platform, "DE")
if err != nil {
http.Error(w, "gespeicherte Extraktion konnte nicht gelesen werden: "+err.Error(), http.StatusInternalServerError)
return
}
storeFindings, err := s.store.ListCurrentFindings(ctx, submissionID)
if err != nil {
http.Error(w, "Findings konnten nicht gelesen werden: "+err.Error(), http.StatusInternalServerError)
return
}
dossierFindings := make([]rules.Finding, len(storeFindings))
for i, f := range storeFindings {
dossierFindings[i] = rules.Finding{
RuleID: f.RuleID, RuleVersion: f.RuleVersion, Severity: rules.Severity(f.Severity),
Title: f.Title, Fix: f.Fix, Sources: f.Sources,
}
}
metadataHash, err := evidence.HashMetadata(struct {
SubmissionID string
Platform string
PostType string
Caption string
Facts rules.Facts
Findings []rules.Finding
}{sub.ID, sub.Platform, sub.PostType, sub.Caption, facts, dossierFindings})
if err != nil {
http.Error(w, "Metadaten-Hash fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
timestampToken, err := s.timestamper.Timestamp(ctx, metadataHash)
if err != nil {
http.Error(w, "Zeitstempel fehlgeschlagen: "+err.Error(), http.StatusBadGateway)
return
}
if err := os.MkdirAll(s.dossierDir, 0o750); err != nil {
http.Error(w, "Dossier-Verzeichnis konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
return
}
dossierPath := filepath.Join(s.dossierDir, submissionID+".pdf")
dossierFile, err := os.Create(dossierPath)
if err != nil {
http.Error(w, "Dossier-Datei konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
return
}
defer dossierFile.Close()
genErr := dossier.Generate(dossierFile, dossier.Data{
Submission: dossier.Submission{
Platform: sub.Platform, PostType: sub.PostType, Caption: sub.Caption, CreatedAt: sub.CreatedAt,
},
Facts: facts,
Findings: dossierFindings,
MetadataHash: metadataHash,
TimestampToken: timestampToken,
GeneratedAt: time.Now(),
})
if genErr != nil {
http.Error(w, "Dossier konnte nicht erzeugt werden: "+genErr.Error(), http.StatusInternalServerError)
return
}
if _, err := s.store.CreateEvidencePackage(ctx, submissionID, dossierPath, hex.EncodeToString(metadataHash), timestampToken); err != nil {
http.Error(w, "Evidence-Package konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
return
}
if err := s.store.SetSubmissionStatus(ctx, submissionID, "published"); err != nil {
http.Error(w, "Status konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError)
return
}
timestampedAt, err := evidence.TimestampTime(timestampToken)
if err != nil {
http.Error(w, "Zeitstempel konnte nicht gelesen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := archivedData{
SubmissionID: submissionID,
DossierURL: "/dossier/" + submissionID,
TimestampedAt: timestampedAt.Format("02.01.2006 15:04:05 MST"),
}
if err := s.templates.ExecuteTemplate(w, "archived", data); err != nil {
http.Error(w, "Ergebnis konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
// handleDossierDownload liefert das erzeugte PDF-Dossier eines
// archivierten Beitrags aus.
func (s *Server) handleDossierDownload(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
ctx := r.Context()
sub, err := s.store.GetSubmission(ctx, id)
if err != nil || sub.AccountID != currentUser(r).AccountID {
http.Error(w, "Kein Dossier für diesen Beitrag gefunden", http.StatusNotFound)
return
}
pkg, err := s.store.GetLatestEvidencePackage(ctx, id)
if err != nil {
http.Error(w, "Kein Dossier für diesen Beitrag gefunden", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/pdf")
http.ServeFile(w, r, pkg.DossierPath)
}