Files
spiele-redaktion/README.md

38 KiB
Raw Blame History

Spiele-Redaktion

Deutsch: README.md · English: README.en.md

KI-Assistenz für Spielemagazin-Redaktionen — Multi-User-Webanwendung mit modularer Plugin-Architektur. Lauffähiger Kern mit Plugin-System, Authentifizierung/Rollen und Migrationen; vollständig implementiert sind bisher Audit-Log, Benachrichtigung, Neuheiten (BGG-Sync), Dedup-Prüfung, Planungsliste, Archiv (12-Monats-Autopilot), Erinnerungen und Export (CSV/PDF).

Stack

  • Backend: Python 3.11+, FastAPI, SQLAlchemy 2 (SQLite, Postgres-fähig)
  • Frontend: HTMX + Jinja2 + Tailwind (CDN) + Alpine.js — kein Build-Step
  • Auth: Session-Cookies (signiert), Passwort-Hashing mit Argon2id
  • Plugins: Entry-Points (importlib.metadata, Gruppe spiele_redaktion.plugins) plus lokales plugins/-Verzeichnis
  • Hintergrund-Jobs: APScheduler (BGG-Neuheiten-Sync)
  • PDF-Export: WeasyPrint (System-Bibliotheken: Pango/fontconfig)
  • Dependencies/Tests: uv + pytest

Setup

uv sync                                   # venv + Dependencies
uv run uvicorn main:app --reload          # Dev-Server auf http://127.0.0.1:8000

Beim ersten Start wird automatisch ein Admin-Konto angelegt:

Benutzername Passwort Rolle
admin admin admin

⚠️ Für alles außer der lokalen Entwicklung vor dem Start SPIELE_INITIAL_ADMIN_PASSWORD setzen und das Passwort nach dem ersten Login ändern.

Umgebungsvariablen

Variable Default Bedeutung
SPIELE_DATABASE_URL sqlite:///./data/spiele-redaktion.db SQLAlchemy-URL (Postgres: z. B. postgresql+psycopg://…)
SPIELE_SESSION_SECRET Dev-Wert Secret für signierte Session-Cookies
SPIELE_SESSION_DAUER 43200 Session-Dauer in Sekunden (12 h)
SPIELE_INITIAL_ADMIN_PASSWORD admin Passwort des initialen Admins
SPIELE_PLUGINS_DIR <Projekt>/plugins Pfad zum lokalen Plugin-Verzeichnis
SPIELE_SMTP_HOST (leer) SMTP-Server für den E-Mail-Kanal; leer = Dev-Fallback (nur Protokoll)
SPIELE_SMTP_PORT 587 SMTP-Port
SPIELE_SMTP_BENUTZER (leer) SMTP-Login (optional)
SPIELE_SMTP_PASSWORT (leer) SMTP-Passwort (optional)
SPIELE_SMTP_ABSENDER Benutzer bzw. spiele-redaktion@localhost From-Adresse
SPIELE_SMTP_TLS starttls starttls, ssl oder keine
SPIELE_TELEGRAM_BOT_TOKEN (leer) Bot-Token für den Telegram-Kanal; leer = Dev-Fallback (nur Protokoll)
SPIELE_BGG_SYNC_AKTIV 1 Hintergrund-Sync des Neuheiten-Plugins an (1) oder aus (0)
SPIELE_BGG_SYNC_INTERVALL_STUNDEN 24 Intervall des BGG-Syncs in Stunden (min. 1)
SPIELE_BGG_SUCHBEGRIFFE brettspiel, gesellschaftsspiel, familienspiel, strategiespiel, kartenspiel, würfelspiel Komma-getrennte Suchbegriffe für den regelmäßigen Sync (deutsche Spielbegriffe)
SPIELE_BGG_MAX_TREFFER_PRO_SUCHE 50 Obergrenze Treffer je Suchbegriff (schont das BGG-Rate-Limit)
SPIELE_BGG_JAHR_FILTER 1 Nur Spiele mit Erscheinungsjahr ≥ aktuelles Jahr übernehmen (1) oder alle (0)
SPIELE_BGG_DEUTSCHE_TITEL_FILTER 1 Nur Spiele mit deutsch klingendem Titel übernehmen (1) oder alle (0)
SPIELE_BGG_TOKEN (leer) API-Token für die BGG-XML-API2, wird als `Authorization: Bearer *** gesendet; seit der Token-Pflicht von BGG erforderlich (siehe BGG-Thread 3602374) — ohne Token wird der Sync übersprungen
SPIELE_DEDUP_BGG_AKTIV 1 BGG-Zusatzdaten für die Dedup-Prüfung an (1) oder aus (0): Alternate-Names und Erweiterungs-Relationen
SPIELE_DEDUP_LLM_AKTIV 0 LLM-Zweitprüfung der Dedup-Grenzfälle an (1) oder aus (0)
SPIELE_DEDUP_LLM_BASIS_URL (leer) Basis-URL einer OpenAI-kompatiblen Chat-Completions-API, z. B. https://api.openai.com/v1
SPIELE_DEDUP_LLM_API_KEY (leer) API-Key, wird als Authorization: Bearer … gesendet
SPIELE_DEDUP_LLM_MODELL (leer) Modellname, z. B. gpt-4o-mini
SPIELE_DEDUP_LLM_KONFIDENZ_MIN 0.7 Mindest-Konfidenz; darunter wird das LLM-Ergebnis verworfen und nur der Regelbefund angezeigt
SPIELE_DEDUP_LLM_TIMEOUT_SEKUNDEN 20 Timeout je LLM-Aufruf; Netzwerk-/Server-Fehler werden genau einmal wiederholt
SPIELE_NEUHEITEN_KI_AKTIV 0 Optionale LLM-Hilfsstufe des Web-Quellen-Crawlers an (1) oder aus (0)
SPIELE_NEUHEITEN_KI_BASIS_URL (leer) Basis-URL einer OpenAI-kompatiblen Chat-Completions-API, z. B. https://api.openai.com/v1
SPIELE_NEUHEITEN_KI_API_KEY (leer) API-Key, wird als Authorization: Bearer … gesendet
SPIELE_NEUHEITEN_KI_MODELL (leer) Modellname, z. B. gpt-4o-mini
SPIELE_NEUHEITEN_KI_KONFIDENZ_MIN 0.7 Mindest-Konfidenz; darunter wird das KI-Ergebnis verworfen und der klassische Parser-Befund behalten
SPIELE_NEUHEITEN_KI_TIMEOUT_SEKUNDEN 20 Timeout je LLM-Aufruf; Netzwerk-/Server-Fehler werden genau einmal wiederholt
SPIELE_ARCHIV_JOB_AKTIV 1 Täglicher Archivierungs-Job an (1) oder aus (0)
SPIELE_ARCHIV_JOB_UHRZEIT 03:00 Tageszeit des täglichen Archiv-Laufs im Format HH:MM
SPIELE_ERINNERUNG_JOB_AKTIV 1 Täglicher Erinnerungs-Check an (1) oder aus (0)
SPIELE_ERINNERUNG_JOB_UHRZEIT 08:00 Uhrzeit (HH:MM) des täglichen Erinnerungs-Checks

Tests

uv run pytest

Abgedeckt: Plugin-Loader lädt alle Plugins (inkl. Lifecycle-Hooks und Entry-Point-Pfad), Migrations-Laufzeit inkl. Idempotenz, Login/Logout, Rollen-Zugriff (admin/redakteur vs. rezensent), Benutzerverwaltung sowie das Audit-Log-Plugin (Logging-Funktion, Migration, Filter, Paginierung, Nur-Admin-Zugriff) sowie das Neuheiten-Plugin: BGG-Parsing, Erweiterungs-/ Prototyp-Filter, Rate-Limit & Retry-Backoff (gemockte HTTP-Antworten, keine echten API-Calls), Update-statt-Duplikat-Logik, UI-Suche/Filter/Sortierung, Rollen am Sync-Endpunkt und Scheduler-Lifecycle. Dazu die beiden neuen Plugins: dedup (Titel-Heuristik, Fuzzy-Match, alle vier Prüfungen mit Fake-BGG-Client, Prüfprotokoll, Prüfseite) und planung (beide Eintragswege mit Stub-gemockter dedup-/Benachrichtigungs-/Audit-API, Verlags-Auswahl- Dialog, Statuswechsel, Bearbeiten/Löschen, Rechte pro Rolle) plus ein Integrationstest mit den echten Plugins (In-App-Nachricht + Audit-Eintrag). Dazu die Coverbilder-Erweiterung: Migrationen für bild_url in allen vier Tabellen inkl. Daten-Erhalt beim ALTER TABLE, BGG-Bildextraktion (<image> bevorzugt, <thumbnail> als Rückfallebene, gemockte Antworten), Bildübernahme aus Web-Quellen, Update-Pflege des Bildes im Sync-Upsert sowie Template-Darstellung (loading="lazy"/decoding="async" bzw. Platzhalter). Dazu das archiv-Plugin: 12-Monats-Regel mit eingefrorener Uhr (Grenzfälle „genau 12 Monate“, Schaltjahr/29. Februar, UTC-Konsistenz bei Zeitzonen-Unterschieden), Job-Lauf mit Audit-Einträgen, Wiederherstellen inkl. BGG-Konflikt und Ersatz-Rezensent, Rollen sowie Suche/Filter in der Admin-Ansicht.

Das Plugin erinnerung ist abgedeckt mit: Ausgaben-CRUD (nur Admin, mit Audit), Erinnerungslogik mit eingefrorener Uhr (freezegun) — 4-Wochen-Grenze (28 Tage exakt), kein Versand vor der Frist und nach dem Redaktionsschluss, Empfängerregeln (offene oder fehlende Planung, Rollen-Filter), Doppelschutz pro Ausgabe+Benutzer, manuelle Prüfung, Scheduler-Lifecycle/Uhrzeit-Konfiguration — wiederum plus ein Integrationstest mit den echten Plugins. Dazu das Plugin export (CSV-Inhalt mit Semikolon/BOM/Umlauten inkl. Quoting, gültige PDF-Dateien via Kopf-/Struktur-Check, Rollen-Zugriff je Liste und Format, Audit-Protokollierung der Downloads; PDF-Tests skippen sauber, wenn die WeasyPrint-System-Bibliotheken fehlen — ein Installationsversuch läuft zuvor automatisch) plus ein Integrationstest mit den echten Plugins., Bearbeiten/Löschen, Rechte pro Rolle), das Plugin export (CSV-Inhalt mit Semikolon/BOM/Umlauten inkl. Quoting, gültige PDF-Dateien via Kopf-/Struktur-Check, Rollen-Zugriff je Liste und Format, Audit-Protokollierung der Downloads; PDF-Tests skippen sauber, wenn die WeasyPrint-System-Bibliotheken fehlen — ein Installationsversuch läuft zuvor automatisch) plus ein Integrationstest mit den echten Plugins (In-App-Nachricht + Audit-Eintrag).

Architektur

src/redaktionskern/        schlanker Kern — KEINE Fachlogik
├── app.py                 App-Fabrik: Discovery → Migrations → on_load → Routen
├── config.py              Einstellungen (Umgebungsvariablen)
├── db.py                  Engine/Session (SQLite ↔ Postgres portabel)
├── migrationen.py         Migrations-Laufzeit (Tabelle schema_migrations pro Plugin)
├── plugin_loader.py       Entry-Points + plugins/-Verzeichnis, eindeutige Namen
├── contracts.py           Plugin-Vertrag (BasePlugin, Migration, NavEntry, PluginContext)
└── auth/                  Login/Logout, Rollen, Benutzerverwaltung

plugins/                   ein Ordner pro Funktion, ladbar über den Plugin-Loader
├── audit-log/              VOLL IMPLEMENTIERT: Model, Migration, API, Admin-Ansicht
├── neuheiten/              VOLL IMPLEMENTIERT: BGG-Client, Sync-Job, Migration, UI
├── benachrichtigung/       VOLL IMPLEMENTIERT: E-Mail/Telegram/In-App
├── dedup/                  VOLL IMPLEMENTIERT: check_titel-API, Heuristik, Protokoll
├── planung/                VOLL IMPLEMENTIERT: Planungsliste, Verschiebung, Dialoge
├── archiv/                 VOLL IMPLEMENTIERT: 12-Monats-Autopilot, Job, UI
├── erinnerung/             VOLL IMPLEMENTIERT: Ausgaben, 4-Wochen-Erinnerung, Tages-Job
├── export/                 VOLL IMPLEMENTIERT: CSV/PDF-Downloads, Rollen, Audit
└── …                       je __init__.py + templates/<name>/

Der Plugin-Vertrag

Ein Plugin ist ein Paketordner unter plugins/ mit einem __init__.py, das ein Modulattribut plugin (Instanz einer BasePlugin-Unterklasse) bereitstellt. Alternativ: installiertes Paket mit Entry-Point in der Gruppe spiele_redaktion.plugins.

from fastapi import Depends, Request
from redaktionskern.auth.deps import require_user
from redaktionskern.contracts import BasePlugin, Migration, NavEntry

class MeinPlugin(BasePlugin):
    name = "meinplugin"          # eindeutig, = Ordnername
    title = "Mein Plugin"        # deutscher Anzeigename
    description = "Was es tut."

    def migrations(self):        # eigene Schema-Migrationen (optional)
        return []

    def navigation(self):        # Einträge in der Hauptnavigation (optional)
        return [NavEntry(label=self.title, url="/meinplugin")]

    def on_load(self, context):  # Lifecycle-Hook beim Start
        super().on_load(context) # context.engine/.session_factory/.templates/
                                 # .settings/.registry (Plugin-Registry für
                                 # Hintergrund-Jobs ohne Request)

    def on_unload(self):         # Lifecycle-Hook beim Herunterfahren
        super().on_unload()

plugin = MeinPlugin()

Der Kern stellt jedem Plugin über context.templates eine gemeinsame Jinja- Umgebung bereit (Plugin-Templates erben base.html), dazu Engine und Session-Fabrik. Migrationen laufen transaktional und werden pro Plugin in schema_migrations protokolliert; die up-Funktionen bekommen eine SQLAlchemy-Connection und können bei Dialekt-Unterschieden verzweigen.

Plugin „Audit-Log“ (implementiert)

Protokolliert, wer was wann verschoben, eingetragen oder geändert hat.

Datentabelle

Eigene Migration 0001_audit_eintraege, Tabelle audit_eintraege (SQLite ↔ Postgres portabel):

Spalte Inhalt
actor_id / actor_name Wer (Benutzer-ID/-Name; System bei automatischen Ereignissen)
action Was (erstellt, verschoben, geaendert, geloescht, benachrichtigt)
objekt_typ / objekt_id Betroffenes Objekt (ID als Text)
alt / neu Alt-/Neustand als JSON (optional)
details Freie Zusatzinformationen als JSON (optional)
ip_adresse Herkunfts-IP (optional)
erstellt_am Zeitstempel

Öffentliche API für andere Plugins

Andere Plugins rufen das Audit-Log über die Plugin-Registry bei jedem relevanten Ereignis auf:

audit = request.app.state.registry.get("audit-log")
if audit is not None:
    await audit.log(                       # async-Variante
        user,                              # User-Objekt, Benutzername oder None (= System)
        "geaendert",                       # Aktion (Konstanten: AKTIONEN_ANZEIGE im Plugin)
        "planungseintrag",                 # Objekttyp
        eintrag.id,                        # Objekt-ID
        details={"alt": alt, "neu": neu},  # „alt“/„neu“ → JSON-Spalten, Rest → details
        ip_adresse=request.client.host,    # optional
    )

In synchronen Routen (def, FastAPI-Threadpool) steht audit.log_sync(...) mit denselben Argumenten bereit.

Admin-Ansicht

Unter /audit-log (nur Rolle Admin; Rezensenten/Redakteure erhalten 403, Anonyme werden zum Login umgeleitet): filterbar nach Benutzer, Aktionstyp und Zeitraum (Von/Bis), paginiert (25 Einträge pro Seite), neueste zuerst.

Plugin „benachrichtigung“

Erstes fachlich umgesetztes Plugin (Adapter-Muster, drei Kanäle):

Kanal Zustellung Empfängerdaten
E-Mail SMTP (smtplib, TLS: STARTTLS/SSL), konfigurierbar per Env E-Mail-Adresse pro Benutzer
Telegram Bot-API (sendMessage) Chat-ID pro Benutzer, Token zentral per Env
In-App Persistente Nachrichten im Portal mit Unread-Counter und „Alle als gelesen markieren“

Ist ein Kanal installationsweit nicht konfiguriert (kein SMTP-Host bzw. kein Bot-Token — der Normalfall in der Entwicklung), protokolliert ein Dev-Log-Adapter die Nachricht auf dem Server, statt sie zu versenden; in der Entwicklung geht so keine Benachrichtigung verloren.

Jeder Benutzer wählt unter Benachrichtigungen → Kanäle & Einstellungen seine Kanäle (mehrere gleichzeitig möglich) und hinterlegt E-Mail-Adresse bzw. Telegram-Chat-ID. Ohne gespeicherte Präferenz wird In-App zugestellt.

Öffentliche Plugin-API für andere Plugins

plugin = request.app.state.registry.get("benachrichtigung")
bericht = await plugin.send_notification(user, "Titel", "Nachrichtentext", "kategorie")
# bericht.zugestellt       -> z. B. ["email", "inapp"]
# bericht.fehlgeschlagen   -> Kanäle ohne Adresse/Fehler

send_notification(user, titel, text, kategorie="allgemein") wirft nicht; einzelne Kanalausfälle werden protokolliert und im Bericht gemeldet. Die In-App-Nachricht landet im Posteingang des Benutzers.

Plugin „neuheiten“ (implementiert)

Neuheitenliste auf Basis der BoardGameGeek XML API2 (https://boardgamegeek.com/xmlapi2). Regelmäßiger Sync als Hintergrund-Job, manuelle Synchronisation für die Redaktion, deutsche sortier-/filterbare Liste.

Datentabelle

Eigene Migrationen 0001_neuheiten_tabelle, 0002_bgg_id_nullable, 0003_quellen_status und 0004_bild_url; Tabelle neuheiten (SQLite ↔ Postgres portabel):

Spalte Inhalt
titel Spieltitel
verlag / autor Verlag bzw. Autor(en), kommagetrennt
erscheinungsjahr Erscheinungsjahr — BGG liefert nur das Jahr, kein genaues Datum
bgg_id BoardGameGeek-ID, eindeutig → Merge-Kriterium des Syncs
bild_url URL des Coverbildes (nullable; BGG-Image/-Thumbnail oder Bild der Web-Quelle)
status neuheit (spätere Plugins: Planung/Archiv)
quelle Datenquelle (boardgamegeek)
erstellt_am / aktualisiert_am Zeitstempel

BGG-Client & Sync-Ablauf

Pro Suchbegriff: /search?type=boardgame&query=… → IDs sammeln → /thing?id=…&type=boardgame in Batches à 20 IDs.

Coverbilder: Aus der Thing-Antwort wird die Bild-URL extrahiert (bevorzugt das große <image>, sonst das kleinere <thumbnail>; protokoll-relative URLs werden auf https normalisiert) und in bild_url gespeichert. Beim Update bestehender Einträge wird das Bild mitgepflegt; fehlt es in einer Antwort, bleibt ein bereits gespeichertes Bild erhalten. Bilder werden direkt von BGG eingebettet (Hotlinking, kein Download) — im UI immer mit loading="lazy" und decoding="async".

  • Rate-Limit: mindestens 1 Sekunde zwischen zwei Requests.
  • Retry mit Backoff: exponentielles Zurückhalten bei 5xx, 429 und Netzwerkfehlern; HTTP 202 (BGG-Warteschlange) wird gemäß Retry-After erneut versucht; kaputtes XML erzeugt eine klare Fehlermeldung.
  • Filter:
    • Erweiterungen (type=boardgameexpansion) werden auf Request-Ebene (type=boardgame) und zusätzlich pro Element ausgeschlossen.
    • Prototypen: Die XML API2 liefert keinen Prototyp-Marker; als Heuristik werden Titel mit Prototyp-Schlüsselwörtern („Prototyp“, „Prototype“, …) gefiltert. Restrisiko verbleibt — redaktionelle Prüfung bleibt wichtig.
  • Update statt Duplikat: bestehende Einträge werden über die eindeutige bgg_id aktualisiert (Titel/Verlag/Autor/Jahr), der status bleibt erhalten; neu angelegt wird nur bei unbekannter BGG-ID.

Hintergrund-Job & manuelle Synchronisation

Beim Start registriert das Plugin einen APScheduler-Job (Standard: alle 24 h, konfigurierbar über SPIELE_BGG_SYNC_INTERVALL_STUNDEN, abschaltbar über SPIELE_BGG_SYNC_AKTIV=0). Die Suchbegriffe kommen aus SPIELE_BGG_SUCHBEGRIFFE (komma-getrennt). Läuft ein Sync noch, wird ein überlappender Durchlauf übersprungen.

Unter Neuheiten → „Jetzt synchronisieren“ lösen Admins und Redakteure einen sofortigen Sync aus — optional mit einem Sofort-Suchbegriff für eine gezielte Recherche. Das Ergebnis (neu/aktualisiert/gefiltert) erscheint als Banner; der Endpunkt ist rollengeschützt (Rezensenten erhalten 403).

BGG-API-Token (Pflicht): BoardGameGeek verlangt seit seiner Umstellung für die XML API2 eine Bearer-Authentifizierung (Thread 3602374). Alle Requests des Plugins senden deshalb den Header Authorization: Bearer <Token>; der Token kommt aus SPIELE_BGG_TOKEN. Ist kein Token gesetzt, wird der Sync gar nicht erst gestartet und mit der Meldung „Kein BGG-API-Token konfiguriert (SPIELE_BGG_TOKEN) — Sync übersprungen.“ abgebrochen. Lehnt BGG eine Anfrage mit HTTP 401 ab (fehlender oder ungültiger Token), erscheint statt der generischen Fehlermeldung der Hinweis, einen gültigen API-Token in SPIELE_BGG_TOKEN zu hinterlegen.

UI

Die Seite /neuheiten zeigt alle Einträge als Tabelle (Spieltitel, Verlag, Autor, Erscheinungsjahr, Status, aktualisiert am):

  • Sortierung per Spaltenkopf, Suche über Titel/Verlag/Autor und Statusfilter — serverseitig umgesetzt, per HTMX ohne Seitenreload (ohne JavaScript als normaler Formular-GET nutzbar).
  • Jeder Titel verlinkt direkt auf den BGG-Eintrag.
  • Sync-Steuerung nur für Rolle Admin/Redakteur.

Web-Quellen-Crawler mit optionaler KI-Hilfsstufe

Neben BGG crawlt das Plugin zusätzlich redaktionell gepflegte Web-Quellen (spielbox.de, brettspielbox.de, spiel-essen.de, cliquenabend.de) über je einen Adapter in plugins/neuheiten/quellen/ — mit Rate-Limit (1 Request/s), Retry/Backoff, konditionalen Requests (ETag/304), Fehler-Isolation pro Quelle und Duplikat-Gate gegen die BGG-Liste. Der Crawler arbeitet standardmäßig rein regelbasiert. Liefert eine Quelle im Listenelement ein Bild (img mit src bzw. data-src), wird dessen URL in bild_url übernommen — sonst bleibt das Feld leer.

Optional kann eine LLM-Hilfsstufe (plugins/neuheiten/quellen/ki_hilfe.py) zugeschaltet werden — konsistent zur Dedup-KI über die SPIELE_NEUHEITEN_KI_*-Variablen (siehe Tabelle oben). Das LLM wird nur in zwei Situationen genutzt:

  • Struktur-Erkennung: Liefert der Regel-Parser keine oder verdächtig wenige Einträge (0 Treffer; 12 Treffer auf einer sehr linkreichen Seite), darf das LLM aus dem gelieferten HTML Pagination-/Filter-Folge-URLs vorschlagen. Der Vorschlag wird im Code hart gefiltert: nur URLs derselben Domain wie die geparste Seite, maximal 20 je Seite, bereits besuchte/geplante URLs werden übersprungen, nicht erreichbare Vorschläge brechen den Lauf nicht ab — Schutz vor Rate-Limit-Überlastung und Domain-Ausbruch.
  • Feld-Extraktion: Einzelne Treffer sind unsicher (fehlender Verlag oder verdächtiger/generischer Titel). Nur solche Treffer gehen ans LLM, das höchstens titel, verlag und autor korrigiert — die URL ist strukturell nicht änderbar.

Unter der Mindest-Konfidenz (SPIELE_NEUHEITEN_KI_KONFIDENZ_MIN, Standard 0.7) wird jedes KI-Ergebnis verworfen; der klassische Parser-Befund bleibt dann unverändert bestehen.

  • Fallback-Pflicht: Standardmäßig aus (SPIELE_NEUHEITEN_KI_AKTIV=0). Ohne vollständige Konfiguration oder bei jedem Fehler (Timeout, Server-Störung nach genau einem Retry, kaputtes JSON) läuft EXAKT der klassische Crawler — ein KI-Fehler kann den Sync nie blockieren.
  • Audit: KI-Eingriffe eines Sync-Laufs landen gebündelt im audit-log (korrigiert / neuheiten_ki_crawler) mit Quelle, Modell, Konfidenz und den jeweils korrigierten Feldern sowie den verfolgten KI-Folge-URLs.

Plugin „dedup“ (implementiert)

Dedup-Prüfungen bei Import und händigem Eintrag. Die Prüf-Logik liegt komplett im Plugin; der Kern bleibt unberührt.

Öffentliche Prüf-API für andere Plugins

dedup = request.app.state.registry.get("dedup")
ergebnis = await dedup.check_titel(titel, verlag, bgg_id, user=user)
# ergebnis.hat_konflikte        -> bool
# ergebnis.konflikte            -> Liste TitelKonflikt (art, beschreibung, details)
# ergebnis.verlags_optionen     -> Verlage zur Auswahl bei Verlags-Konflikt
# ergebnis.titel_empfehlung     -> deutschester bekannter Titel
# ergebnis.als_text()           -> lesbare Zusammenfassung (deutsch)

Die vier Prüfungen

# Prüfung Mechanik
a Verlags-Konflikt Gleiches Spiel (BGG-ID oder Fuzzy-Match) bereits unter anderem Verlag/Vertrieb → Konflikt mit Verlagsauswahl
b Titel-Varianten Deutsche Version wird bevorzugt — Heuristik über deutsche Titel-Indikatoren (Umlaute/ß, Funktionswörter, typische Spielbegriffe) plus BGG Alternate-Names, falls verfügbar
c Bereits besprochen Abgeschlossene Planungseinträge bilden das Korpus; Vorgänger werden über die BGG boardgameexpansion-Relation erkannt und zusätzlich fuzzy auf Titel gematcht (rapidfuzz, Schwelle 85, token_set_ratio)
d Schon in Planung Titel steht bereits in der Planungsliste eines anderen Rezensenten (eigene Einträge zählen nicht)

Fehlt eine Partner-Tabelle (Plugin nicht geladen), entfällt die jeweilige Teilprüfung. BGG-Störungen blockieren nie: der Hilfsclient degradiert auf die reine Heuristik und protokolliert nur. Abschaltbar über SPIELE_DEDUP_BGG_AKTIV=0 (Standard: an, Rate-Limit 1 s).

LLM-Zweitprüfung für Grenzfälle

Regeln stoßen bei Editionen, Big Boxes, Übersetzungen und Verlags-Aliasen an Grenzen („El Grande Big Box“ vs. „El Grande: 25 Jahre“, „Catan Das schnelle Spiel“ vs. „Catan Fast Edition“). Deshalb kann optional ein LLM als zweite Stufe eingeschaltet werden — die Regelprüfung bleibt maßgeblich:

  • Nur Grenzfälle gehen ans LLM: Titel mit gemeinsamen Wort-Tokens, aber Fuzzy-Score unter der Schwelle (max. 5 Paarungen je Prüfung), sowie ein ergänzender Call zum Verlags-/Titel-Hinweis bei einem regelbasierten Verlags-Konflikt. Klare Treffer und klare Non-Treffer kosten kein Token.
  • Kontext: beide Titel inkl. Alternate-Names (BGG-Sync), Verlage und ggf. BGG-Expansion-Relationen.
  • Strukturierte Antwort (JSON): ist_gleiches_spiel, konfidenz, begruendung, empfohlener_verlag, deutscher_titel. Bestätigte Treffer laufen in die üblichen Prüfungen a)d) zurück; ein übernommener deutscher Titel füllt die Titel-Empfehlung, ein Verlags-Hinweis landet im Detail des Verlags-Konflikts. Unter der Mindest-Konfidenz (SPIELE_DEDUP_LLM_KONFIDENZ_MIN, Standard 0.7) wird das Ergebnis verworfen und nur der Regelbefund angezeigt.
  • Fallback-Pflicht: Standardmäßig aus (SPIELE_DEDUP_LLM_AKTIV=0). Ohne Key oder bei jedem Fehler (Timeout, Server-Störung nach genau einem Retry, kaputtes JSON) verhält sich die Prüfung exakt wie rein regelbasiert — das LLM kann die Prüfung nie blockieren.
  • Audit: Jede Prüfung mit LLM-Befunden schreibt einen Eintrag ins audit-log (geprüft / dedup_ki) mit Modell, Konfidenz und Entscheidung je bewertetem Grenzfall.

Schnittstelle: OpenAI-kompatible Chat-Completions-API via httpx, konfiguriert über die SPIELE_DEDUP_LLM_*-Variablen (siehe Tabelle oben).

Prüfprotokoll & Prüfseite

Jede check_titel-Prüfung landet in der eigenen Migration 0001_pruefprotokoll (Tabelle dedup_pruefungen: Titel, Verlag, BGG-ID, Befund als JSON, geprüft von, Zeitpunkt). Unter Dedup-Prüfung (/dedup) kann jeder angemeldete Benutzer einen Titel von Hand prüfen und sieht die letzten 20 Prüfungen.

Plugin „planung“ (implementiert)

Planungsliste mit Zuordnung an Rezensenten und Ausgaben. Beide Eintragswege laufen automatisch durch die dedup-Prüfung.

Datentabelle

Eigene Migrationen 0001_planungsliste und 0002_bild_url, Tabelle planungsliste:

Spalte Inhalt
titel / verlag / autor Spieldaten
bgg_id BoardGameGeek-ID (Match-Kriterium der dedup-Prüfung)
bild_url URL des Coverbildes (nullable; wird beim Verschieben aus den Neuheiten übernommen)
ausgabe Magazin-Ausgabe, z. B. „3/2025“ (mehrere parallel möglich)
rezensent_id Zuordnung (FK auf die Benutzer-Tabelle des Kerns)
status offenin_bearbeitungabgeschlossen
notizen Freitext
quelle neuheiten (verschoben) oder manuell (nachgetragen)
pruefung Befund der dedup-Prüfung beim Anlegen (JSON)
erstellt_am / aktualisiert_am Zeitstempel

Die beiden Eintragswege

  1. Verschiebung aus der Neuheitenliste — Button „→ Zur Planung“ in der Neuheitenliste ruft POST /planung/uebernehmen/<id> auf; der Neuheiten- Eintrag wechselt in den Status planung, der Planungseintrag wird als Quelle neuheiten angelegt.
  2. Händisches Nachtragen — Formular auf der Planungsseite (POST /planung/neu, Quelle manuell).

Bei einem Prüftreffer erhält der eintragende Rezensent eine Benachrichtigung (send_notification des benachrichtigung-Plugins, Kategorie dedup), und es wird ein Audit-Log-Eintrag geschrieben (log des audit-log-Plugins, Befund in details.dedup_konflikte). Bei einem Verlags-Konflikt erscheint zuerst der Auswahl-Dialog „Welcher Verlag soll geführt werden?“ — erst die bestätigte Wahl legt den Eintrag an; die Entscheidung ist auditiert.

UI & Rechte

Unter Planung (/planung) sieht jeder angemeldete Benutzer die komplette Liste mit Zuordnung (Rezensent, Ausgabe, Status, Prüfhinweis-Badge). Rezensenten können nur ihre eigenen Einträge bearbeiten (Statuswechsel, Bearbeiten, Löschen); Admins und Redakteure alle. Jede Änderung wird im Audit-Log protokolliert (erstellt/verschoben/geaendert/geloescht).

Plugin „archiv“ (implementiert)

Automatische Archivierung nach dem Sicherungsdatei-Prinzip: Titel, deren Erscheinungs- bzw. Eintragsdatum länger als 12 Monate zurückliegt, werden vollständig in eigene Archiv-Tabellen verschoben und aus den aktiven Listen entfernt. Nichts wird gelöscht oder verändert — jeder Eintrag lässt sich unverändert zurück in die aktive Liste verschieben.

Datentabellen

Eigene Migrationen 0001_archiv_tabellen und 0002_bild_url (Spiegel-Tabellen, SQLite ↔ Postgres portabel, ohne Fremdschlüssel — archivierte Zeilen überleben auch das Löschen eines Benutzers; bild_url wird mit archiviert und beim Wiederherstellen zurückgeschrieben):

Tabelle Spiegel von Besondere Spalten
archiv_neuheiten neuheiten alle Originalspalten plus quell_id, archiviert_am, archiviert_von, grund, referenz_am
archiv_planung planungsliste alle Originalspalten (inkl. pruefung-JSON) plus dieselben Archiv-Metadaten

Die 12-Monats-Regel

  • Stichtag: „jetzt 12 Monate“ in UTC; archiviert wird, wer streng älter ist — genau 12 Monate gilt noch nicht als „älter als“.
  • Referenzdatum:
    • Neuheiten: das Erscheinungsdatum. BGG liefert nur das Jahr, daher großzügig das Jahresende (31.12., 23:59:59) — ein Titel von 2023 wird erst ab dem 01.01.2025 archiviert. Ohne Jahr zählt das Eintragsdatum.
    • Planungseinträge: das Eintragsdatum, unabhängig vom Status.
  • Zeitzonen: verglichen wird durchgängig in UTC; naive Datenbank- Zeitstempel gelten als UTC, zeitzonenbewusste Uhrzeiten werden nach UTC verschoben (per Tests mit eingefrorener Uhr abgedeckt, inkl. 29. Februar).

Täglicher Hintergrund-Job

Beim Start registriert das Plugin einen APScheduler-Cron-Job (Standard täglich 03:00, konfigurierbar über SPIELE_ARCHIV_JOB_UHRZEIT, abschaltbar über SPIELE_ARCHIV_JOB_AKTIV=0). Pro archiviertem Titel schreibt der Job einen Audit-Log-Eintrag mit dem Akteur „System“ (Aktion verschoben, Grund und Referenzdatum in den Details). Der Registry-Zugriff aus dem Hintergrund-Job läuft über den neuen context.registry-Hook des Plugin-Kontexts.

Admin-Ansicht mit Wiederherstellen

Unter Archiv (/archiv, nur Rolle Admin) werden beide Tabellen als vereinigte Liste angezeigt (neueste Archivierung zuerst):

  • Suche über Titel/Verlag/Autor und Filter nach Herkunft (Neuheiten/Planung).
  • Pro Eintrag: Herkunfts-Badge, Status, Rezensent, Archivierungszeitpunkt und der Grund (z. B. „älter als 12 Monate (Erscheinungsjahr 2023)“).
  • Wiederherstellen verschiebt den Eintrag vollständig zurück in die aktive Liste und protokolliert dies im Audit-Log (Akteur: der Admin). Konfliktfälle werden sicher abgelehnt: existiert in der Neuheitenliste bereits ein aktiver Eintrag mit derselben BGG-ID (erneuter Sync), wird nicht wiederhergestellt; existiert der zugeordnete Rezensent nicht mehr, übernimmt der wiederherstellende Admin die Zuordnung (gemeldet und auditiert). Rezensenten und Redakteure erhalten 403.

Plugin „erinnerung“ (implementiert)

Redaktionsschluss pro Ausgabe mit automatischer 4-Wochen-Erinnerung an alle Rezensenten — mehrere Ausgaben laufen parallel.

Datentabellen

Eigene Migration 0001_ausgaben_und_protokoll, zwei Tabellen (SQLite ↔ Postgres portabel):

Tabelle Spalten Zweck
erinnerung_ausgabe name (z. B. „3/2025“), redaktionsschluss (Datum), Zeitstempel Eine Magazin-Ausgabe; Anlegen/Bearbeiten/Löschen nur für Rolle Admin
erinnerung_protokoll ausgabe_id, user_id, kanaele, erstellt_am Jede versendete Erinnerung; eindeutig über (Ausgabe, Benutzer) → keine Doppelerinnerung

Erinnerungslogik

  • Fenster: 28 Tage (4 Wochen) vor dem Redaktionsschluss bis zum Redaktionsschluss selbst (jeweils inklusive). Davor und danach wird nicht erinnert.
  • Empfänger: alle aktiven Rezensenten, die noch offene Planungseinträge haben (Status ≠ „abgeschlossen“, also „offen“ oder „in Bearbeitung“) oder gar keine Einträge. Wer alles abgeschlossen hat, wird nicht erinnert; Admins und Redakteure nie.
  • Versand: über send_notification des benachrichtigung-Plugins (Kategorie erinnerung) — die Kanäle richten sich nach der Präferenz des Benutzers. Ohne Partner-Plugin wird kein Protokoll geschrieben, damit der nächste Lauf es erneut versucht.
  • Doppelschutz: pro (Ausgabe, Benutzer) wird genau einmal erinnert; der Versand steht in erinnerung_protokoll. Auch ein späteres Bearbeiten des Redaktionsschlusses löst keine zweite Erinnerung aus.
  • Audit: jede Erinnerung als System/benachrichtigt (Objekttyp erinnerung), jede Ausgaben-Änderung als erstellt/geaendert/ geloescht (Objekttyp ausgabe).

Geplanter Job & manuelle Prüfung

APScheduler prüft täglich (Standard 08:00 Uhr, konfigurierbar über SPIELE_ERINNERUNG_JOB_UHRZEIT, abschaltbar über SPIELE_ERINNERUNG_JOB_AKTIV=0). Unter Erinnerungen → „Jetzt prüfen und erinnern“ führt ein Admin denselben Check sofort aus; das Ergebnis erscheint als Banner. Die Uhr liegt zentral in dienst.heute() — Tests frieren sie mit freezegun ein.

UI

Unter Erinnerungen (/erinnerung) sehen alle angemeldeten Benutzer die Ausgaben mit Redaktionsschluss und Restzeit-Badge. Admins verwalten zusätzlich die Ausgaben (anlegen/bearbeiten/löschen), starten den Sofort-Check und sehen den Erinnerungsstatus: wer wurde für welche Ausgabe wann über welche Kanäle erinnert.

Plugin „export“ (implementiert)

Redaktionslisten als Datei: die drei Listen Neuheiten, Planung und Archiv sind je als CSV und PDF unter Export (/export) herunterladbar. Keine eigene Datentabelle — das Plugin liest die Tabellen der Partner-Plugins über die gemeinsame SQLAlchemy-Metadata (ohne Plugin-Importe); fehlt ein Partner, erscheint die Liste als „nicht verfügbar“, statt zu brechen.

Formate

Format Details
CSV stdlib csv, Semikolon-getrennt (Excel mit deutschem Gebietsschema), UTF-8 mit Byte-Order-Mark — Umlaute/ß bleiben erhalten, Felder mit ; werden korrekt gequotet; Auslieferung als StreamingResponse (text/csv; charset=utf-8)
PDF WeasyPrint aus einem generierten HTML: A4-Tabelle mit deutscher Kopfzeile (wird auf jeder Seite wiederholt), Erstell-Datum und Anzahl Einträge im Kopf, Fußzeile „Seite X von Y“; Auslieferung als StreamingResponse (application/pdf)

WeasyPrint wird erst beim PDF-Download importiert: Ohne installierte System-Bibliotheken (Pango & Co.) bleiben App und CSV-Export voll nutzbar, der PDF-Download liefert eine verständliche Fehlermeldung.

Spalten

  • Neuheiten: Titel · Verlag · Autor · Erscheinungsjahr · Status („Neuheit“, „In Planung“, „Archiviert“) · Aktualisiert am
  • Planung: Titel · Verlag · Autor · Ausgabe · Rezensent (aufgelöster Name) · Status (Offen / In Bearbeitung / Abgeschlossen) · Notizen · Aktualisiert am
  • Archiv = Neuheiten-Tabelle gefiltert auf Status „archiviert“ (gefüllt durch den Autopilot des archiv-Plugins)

Rollen & Protokollierung

Admins und Redakteure exportieren alle Listen; Rezensenten nur die Planungsliste. Die Prüfung passiert serverseitig an jedem Download-Endpunkt (403 bei Zugriff ohne Berechtigung, 404 bei unbekannter Liste); auf der Export-Seite sind nicht erlaubte Karten ausgegraut. Jeder Download wird best effort ins Audit-Log geschrieben (Aktion „exportiert“, Format und Zeilenzahl in den Details).

Deployment (später)

Docker/Podman Compose ist vorgesehen (Henry-Lab, danach Kundenhardware). Für Postgres genügt SPIELE_DATABASE_URL; die Migrationen sind portabel geschrieben.

Oberfläche: Coverbilder & responsives Layout

Coverbilder: Die Listen (Neuheiten, Planung, Archiv) zeigen je Eintrag eine kleine Cover-Vorschau (40×56 px, loading="lazy", decoding="async", Alt-Text = Spieltitel); die Detail-/Prüf-Ansichten der Planung zeigen das Cover großformatig (max. 200 px Breite). Fehlt ein Bild, erscheint ein dezenter CSS-Platzhalter mit Spielicon-Zeichen. Eingebettet wird per Hotlink direkt von BGG bzw. der jeweiligen Quelle — es werden keine Bilder heruntergeladen oder lokal gespeichert.

Responsive Verhalten: Das Basis-Layout ist mobil-first:

  • Navigation: Ab Tablet (md:) horizontale Leiste; auf Mobilgeräten ein Burger-Menü (Alpine.js) mit vollhöhen Touch-Zielen.
  • Listen: Auf schmalen Screens (<768 px) kollabieren Neuheiten, Planung und Archiv zu Karten-Ansichten — Cover links, Titel + Metadaten rechts, Status-Badge oben. Desktop behält die Tabellenform.
  • Formulare (Planung, Benutzerverwaltung, Einstellungen): vollbreite Eingabefelder mit Labels oberhalb auf Mobil.
  • Sync-Übersicht und Audit-Log bleiben Tabellen und scrollen horizontal (overflow-x-auto).
  • Buttons/Links haben auf Mobil mindestens 44 px Höhe (Touch-Ziel).

Getestet gegen 375 px (Smartphone), 768 px (Tablet) und Desktop.

Projekt-Fortschritt

Diese Tabelle ist der Live-Status. Sie wird bei jedem Push aktualisiert.

# Baustein Status
1 Kern: FastAPI, Plugin-System, DB/Migrationen, Auth/Rollen, HTMX-Layout fertig (28 Tests grün)
2 Plugin benachrichtigung (E-Mail/Telegram/In-App, User-Präferenzen) fertig
3 Plugin audit-log (Wer/Was/Wann, Admin-Ansicht, öffentliche API) fertig
4 Plugin neuheiten (BGG-Sync, APScheduler, Filter) fertig
5 Plugin dedup (Verlags-Konflikt, deutsche Version, Vorgänger-/Planungs-Check) fertig
6 Plugin planung (Verschiebung, händischer Eintrag + Prüfungen + Benachrichtigung) fertig
7 Plugin archiv (12-Monats-Autopilot, Wiederherstellen, täglicher Job) fertig
8 Plugin erinnerung (Redaktionsschluss pro Ausgabe, 4-Wochen-Erinnerung, Tages-Job) fertig
9 Plugin export (CSV + PDF via WeasyPrint) fertig
10 Integrationstests über alle Plugins 316 Tests grün
11 Deployment auf swen.henry.insight-it.de (Compose + Traefik, Live-Check) live

Legende: fertig · 🔄 in Arbeit · offen · ⚠️ fertig mit offenen Punkten

Offene Punkte (nicht Teil von Phase 1)

  • CSRF-Schutz für Formulare (aktuell SameSite=Lax-Cookie als Basisschutz)
  • WeasyPrint-Systemabhängigkeiten im Container prüfen
  • Tailwind via CDN nur für Dev; für Produktion lokal gehostete Assets

Lizenz

Dieses Projekt steht unter der MIT-Lizenz.