Files
spiele-redaktion/README.md

718 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Spiele-Redaktion
> **Deutsch:** [README.md](README.md) · **English:** [README.en.md](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](https://docs.astral.sh/uv/) + pytest
## Setup
```bash
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](https://boardgamegeek.com/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
```bash
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`.
```python
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:
```python
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 &amp; 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
```python
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](https://boardgamegeek.com/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
```python
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` | `offen` → `in_bearbeitung` → `abgeschlossen` |
| `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](LICENSE).