From de1d62c6f7a8364605798c5952a23c81e623520c Mon Sep 17 00:00:00 2001 From: Oliver G Date: Mon, 24 Aug 2026 16:32:48 +0200 Subject: [PATCH] feat(admin): Freigabe-Seite und Aufraeumen der Status-Altlasten Die Freigabe ist jetzt der taegliche Arbeitsschritt und bekommt eine eigene Seite: /admin/freigabe listet alle wartenden Artikel mit Bild, Score, Tags und dem umgeschriebenen Text lesbar gerendert, dazu die drei Aktionen Freigeben, Neu schreiben, Verwerfen. Nach jeder Aktion geht es zurueck in die Liste, damit sich eine Warteschlange am Stueck abarbeiten laesst. Der Vorschautext ist Modell-Ausgabe ueber fremde Webseiten und laeuft deshalb durch einen Tag-Whitelist-Filter statt roh ins Template. Aufgeraeumt: - Artikelliste zeigte interne Kuerzel statt Klartext - Status `review` (Relevanz-Warnzone) wurde als "Rewrite" ausgegeben - `Rewrite -> Freigegeben` entfernt: zweiter Weg an der Freigabe vorbei - `Freigegeben -> Veroeffentlicht` entfernt: das macht der WP-Sync - API-Statusliste wird abgeleitet statt handgepflegt, sie kannte den neuen Status nicht und lehnte den Wechsel mit 422 ab Behoben: list_articles las content_rewritten nicht mit, die neue Seite haette nie einen Text angezeigt. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 22 +++ backend/app/admin_ui.py | 202 ++++++++++++++++++-- backend/app/main.py | 15 +- backend/app/repositories.py | 6 +- backend/app/workflow.py | 42 +++- backend/templates/admin_article_detail.html | 1 + backend/templates/admin_article_list.html | 7 +- backend/templates/admin_dashboard.html | 3 +- backend/templates/admin_review_queue.html | 163 ++++++++++++++++ backend/templates/admin_schedule.html | 1 + backend/tests/test_article_workflow.py | 19 +- backend/tests/test_editorial_review.py | 109 ++++++++++- docs/AUTOMATION.md | 4 +- docs/KI-VO.md | 18 +- 14 files changed, 565 insertions(+), 47 deletions(-) create mode 100644 backend/templates/admin_review_queue.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f7b422..6caebda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,25 @@ +## [1.8.1] - 2026-08-24 + +### 📋 Freigabe-Seite + - Neue Seite `/admin/freigabe`: alle Artikel im Status „Wartet auf Freigabe" auf einer Arbeitsliste + - Pro Artikel: Hauptbild, Relevanz-Score mit Begründung, Tags, Wortzahl, Alter, Link zum Originalartikel + - Der umgeschriebene Text wird **lesbar gerendert** statt als Markup — durch einen Tag-Whitelist-Filter, weil er aus Modell-Ausgabe über fremde Webseiten stammt + - Drei Aktionen direkt an jedem Artikel: ✅ Freigeben (mit Notizfeld), ✏️ Neu schreiben, ❌ Verwerfen + - Nach jeder Aktion zurück zur Liste, damit sich eine Warteschlange am Stück abarbeiten lässt + - Verlinkt aus der Navigation aller Admin-Seiten, mit Zähler im Dashboard + +### 🧹 Status-Altlasten entfernt + - Artikelliste zeigte interne Kürzel (`pending_review`, `approved`, `error`) — jetzt überall Klartext + - Status `review` (Relevanz-Warnzone 60–79) wurde als „Rewrite" angezeigt, was etwas anderes bedeutet — heißt jetzt „Niedrige Relevanz" + - Übergang `Rewrite → Freigegeben` entfernt: ein zweiter Weg an der Freigabe vorbei + - Übergang `Freigegeben → Veröffentlicht` entfernt: das setzen WordPress bzw. der WP-Sync, nicht die Hand + - API-Statusliste wird aus dem Statusmodell abgeleitet statt handgepflegt (kannte `freigabe` nicht und lehnte den Wechsel mit 422 ab) + +### 🐛 Behoben + - `list_articles` las `content_rewritten` nicht mit — die Freigabe-Seite hätte nie einen Text angezeigt + +--- + ## [1.8.0] - 2026-08-24 ### ⚖️ Redaktionelle Freigabe vor der Veröffentlichung (Art. 50 Abs. 4 KI-VO) diff --git a/backend/app/admin_ui.py b/backend/app/admin_ui.py index 8700a44..10868a3 100644 --- a/backend/app/admin_ui.py +++ b/backend/app/admin_ui.py @@ -1,5 +1,7 @@ from __future__ import annotations +from html import escape +from html.parser import HTMLParser import json from pathlib import Path import re @@ -50,9 +52,11 @@ from .repositories import ( ) from .workflow import ( ALLOWED_UI_TRANSITIONS, + INTERNAL_STATUSES, UI_STATUS_LABELS, UI_STATUSES, internal_to_ui_status, + ui_status_label, ui_to_internal_status, ) @@ -61,9 +65,10 @@ router = APIRouter(tags=["admin-ui"]) templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / "templates")) ALLOWED_TRANSITIONS: dict[str, tuple[str, ...]] = { "new": ("rewrite", "close"), - "rewrite": ("freigabe", "publish", "close"), + "relevanz": ("rewrite", "close"), + "rewrite": ("freigabe", "close"), "freigabe": ("publish", "rewrite", "close"), - "publish": ("published", "close"), + "publish": ("close",), "published": ("rewrite", "close"), "close": ("rewrite",), "no_image": ("rewrite", "close"), @@ -180,6 +185,85 @@ def _build_image_entries(article: dict, extraction: dict, meta: dict) -> list[di return entries +_PREVIEW_ALLOWED_TAGS = { + "p", "br", "h2", "h3", "h4", "ul", "ol", "li", + "strong", "b", "em", "i", "blockquote", "a", +} +_PREVIEW_DROPPED_CONTENT_TAGS = {"script", "style"} + + +class _PreviewSanitizer(HTMLParser): + """Rebuild article HTML with a small whitelist of tags. + + The rewrite text comes from a language model fed with scraped source pages, + and the Freigabe page renders it instead of showing raw markup — so it gets + filtered rather than trusted. Disallowed tags are dropped, their text kept. + """ + + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.parts: list[str] = [] + self._suppress_depth = 0 + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if tag in _PREVIEW_DROPPED_CONTENT_TAGS: + self._suppress_depth += 1 + return + if self._suppress_depth or tag not in _PREVIEW_ALLOWED_TAGS: + return + if tag == "a": + href = next((v or "" for k, v in attrs if k == "href"), "") + if href.lower().startswith(("http://", "https://")): + self.parts.append(f'') + else: + self.parts.append("") + return + self.parts.append(f"<{tag}>") + + def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if not self._suppress_depth and tag == "br": + self.parts.append("
") + + def handle_endtag(self, tag: str) -> None: + if tag in _PREVIEW_DROPPED_CONTENT_TAGS: + self._suppress_depth = max(0, self._suppress_depth - 1) + return + if self._suppress_depth or tag not in _PREVIEW_ALLOWED_TAGS or tag == "br": + return + self.parts.append(f"") + + def handle_data(self, data: str) -> None: + if not self._suppress_depth: + self.parts.append(escape(data)) + + +def _sanitize_preview_html(raw: str | None) -> str: + text = (raw or "").strip() + if not text: + return "" + parser = _PreviewSanitizer() + try: + parser.feed(text) + parser.close() + except Exception: + return f"

{escape(re.sub(r'<[^>]+>', ' ', text))}

" + cleaned = "".join(parser.parts).strip() + if not cleaned: + return "" + # Plain text without any block tag would collapse into one run. + if "

" not in cleaned and "

" not in cleaned and "