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 <noreply@anthropic.com>
This commit is contained in:
Oliver 2026-08-24 16:32:48 +02:00
parent ea5260716e
commit de1d62c6f7
No known key found for this signature in database
14 changed files with 565 additions and 47 deletions

View file

@ -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 6079) 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 ## [1.8.0] - 2026-08-24
### ⚖️ Redaktionelle Freigabe vor der Veröffentlichung (Art. 50 Abs. 4 KI-VO) ### ⚖️ Redaktionelle Freigabe vor der Veröffentlichung (Art. 50 Abs. 4 KI-VO)

View file

@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from html import escape
from html.parser import HTMLParser
import json import json
from pathlib import Path from pathlib import Path
import re import re
@ -50,9 +52,11 @@ from .repositories import (
) )
from .workflow import ( from .workflow import (
ALLOWED_UI_TRANSITIONS, ALLOWED_UI_TRANSITIONS,
INTERNAL_STATUSES,
UI_STATUS_LABELS, UI_STATUS_LABELS,
UI_STATUSES, UI_STATUSES,
internal_to_ui_status, internal_to_ui_status,
ui_status_label,
ui_to_internal_status, ui_to_internal_status,
) )
@ -61,9 +65,10 @@ router = APIRouter(tags=["admin-ui"])
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / "templates")) templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / "templates"))
ALLOWED_TRANSITIONS: dict[str, tuple[str, ...]] = { ALLOWED_TRANSITIONS: dict[str, tuple[str, ...]] = {
"new": ("rewrite", "close"), "new": ("rewrite", "close"),
"rewrite": ("freigabe", "publish", "close"), "relevanz": ("rewrite", "close"),
"rewrite": ("freigabe", "close"),
"freigabe": ("publish", "rewrite", "close"), "freigabe": ("publish", "rewrite", "close"),
"publish": ("published", "close"), "publish": ("close",),
"published": ("rewrite", "close"), "published": ("rewrite", "close"),
"close": ("rewrite",), "close": ("rewrite",),
"no_image": ("rewrite", "close"), "no_image": ("rewrite", "close"),
@ -180,6 +185,85 @@ def _build_image_entries(article: dict, extraction: dict, meta: dict) -> list[di
return entries 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'<a href="{escape(href, quote=True)}" target="_blank" rel="noopener">')
else:
self.parts.append("<a>")
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("<br />")
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"</{tag}>")
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"<p>{escape(re.sub(r'<[^>]+>', ' ', text))}</p>"
cleaned = "".join(parser.parts).strip()
if not cleaned:
return ""
# Plain text without any block tag would collapse into one run.
if "<p>" not in cleaned and "<h2>" not in cleaned and "<ul>" not in cleaned:
cleaned = f"<p>{cleaned}</p>"
return cleaned
def _safe_admin_redirect(value: str | None, fallback: str) -> str:
"""Only allow redirects back into the admin UI, never to another host."""
target = (value or "").strip()
if target.startswith("/admin/") and "//" not in target[1:] and "\\" not in target:
return target
return fallback
def _publish_readiness(article: dict, meta: dict) -> tuple[bool, list[str]]: def _publish_readiness(article: dict, meta: dict) -> tuple[bool, list[str]]:
reasons: list[str] = [] reasons: list[str] = []
status_ui = internal_to_ui_status(article.get("status")) status_ui = internal_to_ui_status(article.get("status"))
@ -852,30 +936,34 @@ def admin_review_article(request: Request, article_id: int, decision: str = Form
@router.post("/admin/articles/{article_id}/rewrite-run") @router.post("/admin/articles/{article_id}/rewrite-run")
def admin_rewrite_run(request: Request, article_id: int): def admin_rewrite_run(request: Request, article_id: int, redirect_to: str = Form("")):
user = _admin_user(request) user = _admin_user(request)
if not user: if not user:
return RedirectResponse(url="/admin/login", status_code=303) return RedirectResponse(url="/admin/login", status_code=303)
back = _safe_admin_redirect(redirect_to, f"/admin/articles/{article_id}")
sep = "&" if "?" in back else "?"
def _back(msg: str, msg_type: str = "success"):
return RedirectResponse(url=f"{back}{sep}msg={quote_plus(msg)}&type={msg_type}", status_code=303)
article = get_article_by_id(article_id) article = get_article_by_id(article_id)
if not article: if not article:
return _dashboard_redirect(msg=f"Artikel #{article_id} nicht gefunden", msg_type="error") return _dashboard_redirect(msg=f"Artikel #{article_id} nicht gefunden", msg_type="error")
if internal_to_ui_status(article.get("status")) not in {"new", "rewrite", "freigabe", "no_image"}: if internal_to_ui_status(article.get("status")) not in {"new", "rewrite", "freigabe", "no_image"}:
return _dashboard_redirect(msg=f"Rewrite nur aus new/rewrite/freigabe fuer Artikel #{article_id}", msg_type="error") return _back(f"Rewrite fuer Artikel #{article_id} in diesem Status nicht moeglich", "error")
try: try:
rewritten = rewrite_article_text(article) rewritten = rewrite_article_text(article)
tags = generate_article_tags(article, rewritten_text=rewritten) tags = generate_article_tags(article, rewritten_text=rewritten)
except Exception as exc: except Exception as exc:
return _dashboard_redirect(msg=f"Rewrite fehlgeschlagen fuer Artikel #{article_id}: {exc}", msg_type="error") return _back(f"Rewrite fehlgeschlagen fuer Artikel #{article_id}: {exc}", "error")
merged_meta = merge_generated_tags(article.get("meta_json"), tags) merged_meta = merge_generated_tags(article.get("meta_json"), tags)
new_status = post_rewrite_status() new_status = post_rewrite_status()
_upsert_article_from_existing(article, content_rewritten=rewritten, status=new_status, meta_json=merged_meta) _upsert_article_from_existing(article, content_rewritten=rewritten, status=new_status, meta_json=merged_meta)
if new_status == "pending_review": if new_status == "pending_review":
clear_article_editorial_review(article_id) clear_article_editorial_review(article_id)
target = "Freigabe" if new_status == "pending_review" else "publish" target = ui_status_label(internal_to_ui_status(new_status))
return RedirectResponse( return _back(f"Artikel #{article_id} neu geschrieben -> {target}")
url=f"/admin/articles/{article_id}?msg={quote_plus(f'Rewrite fertig -> {target}')}&type=success",
status_code=303,
)
@router.post("/admin/rewrite/run") @router.post("/admin/rewrite/run")
@ -948,8 +1036,59 @@ def admin_reopen_article(request: Request, article_id: int):
) )
@router.get("/admin/freigabe", response_class=HTMLResponse)
def admin_review_queue(request: Request):
"""The daily work list: every article waiting for the editorial sign-off.
Everything needed to decide is on this one page the rewritten text is
rendered to be read, not shown as markup and each article carries its three
actions, so a queue can be worked through without ever leaving the page.
"""
user = _admin_user(request)
if not user:
return RedirectResponse(url="/admin/login", status_code=303)
articles = list_articles(limit=100, status_filter="pending_review")
for article in articles:
meta = _parse_meta_json(article.get("meta_json"))
image_review = meta.get("image_review") if isinstance(meta.get("image_review"), dict) else {}
selected = image_review.get("selected_url") if isinstance(image_review.get("selected_url"), str) else None
article["selected_image_url"] = selected
article["selected_image_proxy_url"] = (
f"/admin/images/proxy?{urlencode({'url': selected})}" if selected else None
)
relevance = meta.get("relevance") if isinstance(meta.get("relevance"), dict) else {}
article["relevance_score"] = relevance.get("score")
article["relevance_reason"] = relevance.get("reason")
tags = meta.get("generated_tags") if isinstance(meta.get("generated_tags"), list) else []
article["generated_tags"] = [str(t) for t in tags if t]
article["preview_html"] = _sanitize_preview_html(article.get("content_rewritten"))
article["days_old"] = article_age_days(article.get("published_at"))
article["source_display"] = (
article.get("source_name_snapshot") or article.get("feed_name") or "unbekannte Quelle"
)
return templates.TemplateResponse(
request,
"admin_review_queue.html",
{
"request": request,
"title": "Freigabe",
"user": user,
"articles": articles,
"flash_msg": request.query_params.get("msg", ""),
"flash_type": request.query_params.get("type", "success"),
},
)
@router.post("/admin/articles/{article_id}/approve") @router.post("/admin/articles/{article_id}/approve")
def admin_approve_article(request: Request, article_id: int, note: str = Form("")): def admin_approve_article(
request: Request,
article_id: int,
note: str = Form(""),
redirect_to: str = Form(""),
):
"""Editorial sign-off: a person read the article and takes responsibility. """Editorial sign-off: a person read the article and takes responsibility.
Stamps reviewer and system time, then hands the article to WordPress and the Stamps reviewer and system time, then hands the article to WordPress and the
@ -959,32 +1098,50 @@ def admin_approve_article(request: Request, article_id: int, note: str = Form(""
if not user: if not user:
return RedirectResponse(url="/admin/login", status_code=303) return RedirectResponse(url="/admin/login", status_code=303)
back = _safe_admin_redirect(redirect_to, f"/admin/articles/{article_id}")
sep = "&" if "?" in back else "?"
try: try:
result = approve_article(article_id, actor=user, note=note.strip() or None) result = approve_article(article_id, actor=user, note=note.strip() or None)
except ValueError as exc: except ValueError as exc:
return RedirectResponse( return RedirectResponse(
url=f"/admin/articles/{article_id}?msg={quote_plus(str(exc))}&type=error", url=f"{back}{sep}msg={quote_plus(f'Artikel #{article_id}: {exc}')}&type=error",
status_code=303, status_code=303,
) )
except Exception as exc: except Exception as exc:
return RedirectResponse( return RedirectResponse(
url=f"/admin/articles/{article_id}?msg={quote_plus(f'Freigabe fehlgeschlagen: {exc}')}&type=error", url=f"{back}{sep}msg={quote_plus(f'Freigabe von #{article_id} fehlgeschlagen: {exc}')}&type=error",
status_code=303, status_code=303,
) )
slot = result.get("scheduled_publish_at") or "-" slot = result.get("scheduled_publish_at") or "-"
return RedirectResponse( return RedirectResponse(
url=f"/admin/articles/{article_id}?msg={quote_plus(f'Freigegeben. Veroeffentlichung geplant fuer {slot}.')}&type=success", url=f"{back}{sep}msg={quote_plus(f'Artikel #{article_id} freigegeben. Veroeffentlichung geplant fuer {slot}.')}&type=success",
status_code=303, status_code=303,
) )
@router.post("/admin/articles/{article_id}/transition") @router.post("/admin/articles/{article_id}/transition")
def admin_transition_article(request: Request, article_id: int, target_status: str = Form(...), note: str = Form("")): def admin_transition_article(
request: Request,
article_id: int,
target_status: str = Form(...),
note: str = Form(""),
redirect_to: str = Form(""),
):
user = _admin_user(request) user = _admin_user(request)
if not user: if not user:
return RedirectResponse(url="/admin/login", status_code=303) return RedirectResponse(url="/admin/login", status_code=303)
def _back(msg: str, msg_type: str = "success"):
if not redirect_to:
return _dashboard_redirect(msg=msg, msg_type=msg_type)
target = _safe_admin_redirect(redirect_to, "/admin/dashboard")
sep = "&" if "?" in target else "?"
return RedirectResponse(
url=f"{target}{sep}msg={quote_plus(msg)}&type={msg_type}", status_code=303
)
article = get_article_by_id(article_id) article = get_article_by_id(article_id)
if article: if article:
current_ui = internal_to_ui_status(article.get("status")) current_ui = internal_to_ui_status(article.get("status"))
@ -995,10 +1152,12 @@ def admin_transition_article(request: Request, article_id: int, target_status: s
# Route it through the same door so it cannot slip into WordPress # Route it through the same door so it cannot slip into WordPress
# unstamped and unscheduled. # unstamped and unscheduled.
if target_ui == "publish" and not (article.get("editorial_review_at") or ""): if target_ui == "publish" and not (article.get("editorial_review_at") or ""):
return admin_approve_article(request, article_id, note=note) return admin_approve_article(request, article_id, note=note, redirect_to=redirect_to)
update_article_status(article_id, target_internal, actor=user, note=note or None) update_article_status(article_id, target_internal, actor=user, note=note or None)
return _dashboard_redirect(msg=f"Artikel #{article_id}: {current_ui} -> {target_ui}") return _back(
return _dashboard_redirect(msg=f"Ungueltiger Statuswechsel fuer Artikel #{article_id}", msg_type="error") f"Artikel #{article_id}: {ui_status_label(current_ui)} -> {ui_status_label(target_ui)}"
)
return _back(f"Ungueltiger Statuswechsel fuer Artikel #{article_id}", msg_type="error")
_PAGE_SIZE = 50 _PAGE_SIZE = 50
@ -1023,6 +1182,8 @@ def admin_article_list(request: Request):
# Enrich each article with thumbnail URL # Enrich each article with thumbnail URL
for a in articles: for a in articles:
a["status_ui"] = internal_to_ui_status(a.get("status"))
a["status_label"] = ui_status_label(a["status_ui"])
meta = _parse_meta_json(a.get("meta_json")) meta = _parse_meta_json(a.get("meta_json"))
image_review = meta.get("image_review") if isinstance(meta.get("image_review"), dict) else {} image_review = meta.get("image_review") if isinstance(meta.get("image_review"), dict) else {}
sel = image_review.get("selected_url") if isinstance(image_review.get("selected_url"), str) else None sel = image_review.get("selected_url") if isinstance(image_review.get("selected_url"), str) else None
@ -1047,6 +1208,11 @@ def admin_article_list(request: Request):
"total_pages": total_pages, "total_pages": total_pages,
"total": total, "total": total,
"page_size": _PAGE_SIZE, "page_size": _PAGE_SIZE,
# Wert bleibt der interne Status (danach wird gefiltert), angezeigt
# wird die Klartext-Bezeichnung.
"status_options": [
(s, ui_status_label(internal_to_ui_status(s))) for s in INTERNAL_STATUSES
],
"status_filter": status_filter or "", "status_filter": status_filter or "",
"search": search or "", "search": search or "",
"flash_msg": request.query_params.get("msg", ""), "flash_msg": request.query_params.get("msg", ""),

View file

@ -47,7 +47,13 @@ from .repositories import (
update_article_status, update_article_status,
upsert_article as repo_upsert_article, upsert_article as repo_upsert_article,
) )
from .workflow import ALLOWED_UI_TRANSITIONS, UI_STATUSES, internal_to_ui_status, ui_to_internal_status from .workflow import (
ALLOWED_UI_TRANSITIONS,
INTERNAL_STATUSES,
UI_STATUSES,
internal_to_ui_status,
ui_to_internal_status,
)
settings = get_settings() settings = get_settings()
@ -135,8 +141,13 @@ class IngestionRunRequest(BaseModel):
feed_id: int | None = None feed_id: int | None = None
# Abgeleitet statt handgepflegt: die Liste ist frueher beim Einfuehren eines
# neuen Status stillschweigend veraltet und hat den Wechsel mit 422 abgelehnt.
_TRANSITION_TARGET_PATTERN = "^(" + "|".join(sorted(set(UI_STATUSES) | set(INTERNAL_STATUSES))) + ")$"
class ArticleTransitionRequest(BaseModel): class ArticleTransitionRequest(BaseModel):
target_status: str = Field(pattern="^(new|rewrite|publish|published|close|review|approved|error|no_image)$") target_status: str = Field(pattern=_TRANSITION_TARGET_PATTERN)
note: str | None = None note: str | None = None

View file

@ -876,7 +876,8 @@ def list_articles(limit: int = 100, status_filter: str | None = None) -> list[di
rows = conn.execute( rows = conn.execute(
""" """
SELECT a.id, a.feed_id, a.source_article_id, a.source_hash, a.title, a.source_url, a.canonical_url, a.published_at, a.author, SELECT a.id, a.feed_id, a.source_article_id, a.source_hash, a.title, a.source_url, a.canonical_url, a.published_at, a.author,
a.summary, a.content_raw, a.word_count, a.status, a.meta_json, a.created_at, a.updated_at, f.name AS feed_name, a.summary, a.content_raw, a.content_rewritten,
a.word_count, a.status, a.meta_json, a.created_at, a.updated_at, f.name AS feed_name,
a.image_urls_json, a.press_contact, a.source_name_snapshot, a.source_terms_url_snapshot, a.image_urls_json, a.press_contact, a.source_name_snapshot, a.source_terms_url_snapshot,
a.source_license_name_snapshot, a.legal_checked, a.legal_checked_at, a.legal_note, a.source_license_name_snapshot, a.legal_checked, a.legal_checked_at, a.legal_note,
a.wp_post_id, a.wp_post_url, a.publish_attempts, a.publish_last_error, a.published_to_wp_at, a.wp_post_id, a.wp_post_url, a.publish_attempts, a.publish_last_error, a.published_to_wp_at,
@ -893,7 +894,8 @@ def list_articles(limit: int = 100, status_filter: str | None = None) -> list[di
rows = conn.execute( rows = conn.execute(
""" """
SELECT a.id, a.feed_id, a.source_article_id, a.source_hash, a.title, a.source_url, a.canonical_url, a.published_at, a.author, SELECT a.id, a.feed_id, a.source_article_id, a.source_hash, a.title, a.source_url, a.canonical_url, a.published_at, a.author,
a.summary, a.content_raw, a.word_count, a.status, a.meta_json, a.created_at, a.updated_at, f.name AS feed_name, a.summary, a.content_raw, a.content_rewritten,
a.word_count, a.status, a.meta_json, a.created_at, a.updated_at, f.name AS feed_name,
a.image_urls_json, a.press_contact, a.source_name_snapshot, a.source_terms_url_snapshot, a.image_urls_json, a.press_contact, a.source_name_snapshot, a.source_terms_url_snapshot,
a.source_license_name_snapshot, a.legal_checked, a.legal_checked_at, a.legal_note, a.source_license_name_snapshot, a.legal_checked, a.legal_checked_at, a.legal_note,
a.wp_post_id, a.wp_post_url, a.publish_attempts, a.publish_last_error, a.published_to_wp_at, a.wp_post_id, a.wp_post_url, a.publish_attempts, a.publish_last_error, a.published_to_wp_at,

View file

@ -1,18 +1,32 @@
from __future__ import annotations from __future__ import annotations
UI_STATUSES = ("new", "rewrite", "freigabe", "publish", "published", "close", "no_image") UI_STATUSES = ("new", "relevanz", "rewrite", "freigabe", "publish", "published", "close", "no_image")
# Menschenlesbare Beschriftungen fuer die Admin-UI. # Menschenlesbare Beschriftungen fuer die Admin-UI. Interne Kuerzel tauchen in
# der Oberflaeche nirgends mehr auf.
UI_STATUS_LABELS: dict[str, str] = { UI_STATUS_LABELS: dict[str, str] = {
"new": "Neu", "new": "Neu",
"rewrite": "Rewrite", "relevanz": "Niedrige Relevanz",
"rewrite": "Rewrite nötig",
"freigabe": "Wartet auf Freigabe", "freigabe": "Wartet auf Freigabe",
"publish": "Freigegeben / eingeplant", "publish": "Freigegeben / eingeplant",
"published": "Veroeffentlicht", "published": "Veröffentlicht",
"close": "Verworfen", "close": "Verworfen",
"no_image": "Kein Bild", "no_image": "Kein Bild",
} }
# Reihenfolge fuer Status-Filter: entlang des Ablaufs, nicht alphabetisch.
INTERNAL_STATUSES = (
"new",
"review",
"rewrite",
"pending_review",
"approved",
"published",
"error",
"no_image",
)
def internal_to_ui_status(status: str | None) -> str: def internal_to_ui_status(status: str | None) -> str:
value = (status or "").strip() value = (status or "").strip()
@ -23,7 +37,10 @@ def internal_to_ui_status(status: str | None) -> str:
if value == "pending_review": if value == "pending_review":
return "freigabe" return "freigabe"
if value == "review": if value == "review":
return "rewrite" # Relevanz-Warnzone (Score zwischen WARN und AUTO): wartet auf die
# Entscheidung, ob der Artikel ueberhaupt verarbeitet wird. Wurde frueher
# als "rewrite" angezeigt, was etwas anderes bedeutet.
return "relevanz"
if value in {"new", "rewrite", "published", "no_image"}: if value in {"new", "rewrite", "published", "no_image"}:
return value return value
return value or "new" return value or "new"
@ -37,6 +54,8 @@ def ui_to_internal_status(status: str | None) -> str:
return "error" return "error"
if value == "freigabe": if value == "freigabe":
return "pending_review" return "pending_review"
if value == "relevanz":
return "review"
if value in {"new", "rewrite", "published", "no_image"}: if value in {"new", "rewrite", "published", "no_image"}:
return value return value
if value in {"approved", "error", "review", "pending_review"}: if value in {"approved", "error", "review", "pending_review"}:
@ -49,14 +68,17 @@ def ui_status_label(status: str | None) -> str:
return UI_STATUS_LABELS.get(value, value or "new") return UI_STATUS_LABELS.get(value, value or "new")
# `freigabe` ist das Tor zur Veroeffentlichung: nur ueber diesen Zustand (oder # `freigabe` ist das einzige Tor zur Veroeffentlichung: der Wechsel nach
# einen bewussten manuellen Wechsel) erreicht ein Artikel `publish`, und beides # `publish` stempelt die redaktionelle Freigabe (Art. 50 Abs. 4 KI-VO).
# stempelt die redaktionelle Freigabe (Art. 50 Abs. 4 KI-VO). # Der frueher moegliche Sprung `rewrite -> publish` ist bewusst entfernt - er war
# ein zweiter Weg an der Freigabe vorbei und im Menue nur verwirrend.
# `published` setzt WordPress bzw. der Publisher, nicht die Hand.
ALLOWED_UI_TRANSITIONS: dict[str, set[str]] = { ALLOWED_UI_TRANSITIONS: dict[str, set[str]] = {
"new": {"rewrite", "close"}, "new": {"rewrite", "close"},
"rewrite": {"freigabe", "publish", "close"}, "relevanz": {"rewrite", "close"},
"rewrite": {"freigabe", "close"},
"freigabe": {"publish", "rewrite", "close"}, "freigabe": {"publish", "rewrite", "close"},
"publish": {"published", "close"}, "publish": {"close"},
"published": {"rewrite", "close"}, "published": {"rewrite", "close"},
"close": {"rewrite"}, "close": {"rewrite"},
"no_image": {"rewrite", "close"}, "no_image": {"rewrite", "close"},

View file

@ -14,6 +14,7 @@
</div> </div>
<div class="row"> <div class="row">
<a class="linkbtn" href="/admin/dashboard">Zurück</a> <a class="linkbtn" href="/admin/dashboard">Zurück</a>
<a class="linkbtn" href="/admin/freigabe">Freigabe</a>
<form method="post" action="/admin/logout"> <form method="post" action="/admin/logout">
<button type="submit" class="secondary">Logout</button> <button type="submit" class="secondary">Logout</button>
</form> </form>

View file

@ -46,6 +46,7 @@
</div> </div>
<div class="row"> <div class="row">
<a class="linkbtn" href="/admin/dashboard">Dashboard</a> <a class="linkbtn" href="/admin/dashboard">Dashboard</a>
<a class="linkbtn" href="/admin/freigabe">Freigabe</a>
<a class="linkbtn" href="/admin/schedule">Veröffentlichungsplan</a> <a class="linkbtn" href="/admin/schedule">Veröffentlichungsplan</a>
<form method="post" action="/admin/logout"> <form method="post" action="/admin/logout">
<button type="submit" class="secondary">Logout</button> <button type="submit" class="secondary">Logout</button>
@ -72,8 +73,8 @@
<label>Status</label> <label>Status</label>
<select name="status_filter"> <select name="status_filter">
<option value="">Alle</option> <option value="">Alle</option>
{% for s in ["new","review","pending_review","approved","published","error","no_image"] %} {% for value, label in status_options %}
<option value="{{ s }}" {% if status_filter == s %}selected{% endif %}>{{ s }}</option> <option value="{{ value }}" {% if status_filter == value %}selected{% endif %}>{{ label }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
@ -139,7 +140,7 @@
{% endif %} {% endif %}
</td> </td>
<td> <td>
<span class="badge-sm badge-{{ a.status }}">{{ a.status }}</span> <span class="badge-sm badge-{{ a.status }}">{{ a.status_label }}</span>
</td> </td>
<td style="font-size:0.82em;"> <td style="font-size:0.82em;">
{% if a.scheduled_publish_at %} {% if a.scheduled_publish_at %}

View file

@ -13,6 +13,7 @@
<p>Angemeldet als <strong>{{ user }}</strong></p> <p>Angemeldet als <strong>{{ user }}</strong></p>
</div> </div>
<div class="row"> <div class="row">
<a class="linkbtn" href="/admin/freigabe">Freigabe{% if pending_review_count %} ({{ pending_review_count }}){% endif %}</a>
<a class="linkbtn" href="/admin/article-list">Artikelliste</a> <a class="linkbtn" href="/admin/article-list">Artikelliste</a>
<a class="linkbtn" href="/admin/schedule">Veröffentlichungsplan</a> <a class="linkbtn" href="/admin/schedule">Veröffentlichungsplan</a>
<a class="linkbtn" href="/admin/connectivity">Connectivity Check</a> <a class="linkbtn" href="/admin/connectivity">Connectivity Check</a>
@ -239,7 +240,7 @@
{% if pending_review_count %} {% if pending_review_count %}
<p class="flash flash-success" style="padding:8px 12px;"> <p class="flash flash-success" style="padding:8px 12px;">
📝 <strong>{{ pending_review_count }}</strong> Artikel warten auf deine redaktionelle Freigabe — 📝 <strong>{{ pending_review_count }}</strong> Artikel warten auf deine redaktionelle Freigabe —
<a href="/admin/dashboard?status_filter=freigabe">jetzt prüfen</a>. <a href="/admin/freigabe">jetzt prüfen</a>.
Erst nach der Freigabe gehen sie nach WordPress. Erst nach der Freigabe gehen sie nach WordPress.
</p> </p>
{% endif %} {% endif %}

View file

@ -0,0 +1,163 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{ title }}</title>
<link rel="stylesheet" href="/admin/static/admin.css" />
<style>
.queue-intro { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
.queue-count { font-size: 1.6em; font-weight: 700; }
.queue-index { margin: 0; padding-left: 1.2em; }
.queue-index li { margin: 2px 0; }
.review-item { border-left: 4px solid #f59e0b; }
.review-head { display: flex; gap: 16px; align-items: flex-start; flex-wrap: wrap; }
.review-head h2 { margin: 0 0 6px 0; }
.review-facts { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-bottom: 6px; }
.review-thumb { width: 180px; flex: 0 0 180px; }
.review-thumb img { width: 100%; border-radius: 6px; display: block; }
.review-headtext { flex: 1 1 320px; min-width: 260px; }
.review-text {
max-width: 72ch; margin: 14px 0; padding: 14px 18px;
background: #fcfcfd; border: 1px solid #e5e7eb; border-radius: 8px;
line-height: 1.65;
}
.review-text h2 { font-size: 1.15em; margin: 1.1em 0 0.3em; }
.review-text h3 { font-size: 1.05em; margin: 1em 0 0.3em; }
.review-text p { margin: 0.6em 0; }
.review-text ul, .review-text ol { margin: 0.6em 0; padding-left: 1.4em; }
.review-actions {
display: flex; gap: 10px; flex-wrap: wrap; align-items: center;
border-top: 1px solid #e5e7eb; padding-top: 12px; margin-top: 4px;
}
.review-actions .approve { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.review-actions .approve input[name="note"] { min-width: 220px; }
.review-actions .spacer { flex: 1 1 auto; }
.btn-approve { background: #047857; }
.score-hi { background: #d1fae5; color: #065f46; }
.score-mid { background: #fef3c7; color: #92400e; }
.queue-empty { text-align: center; padding: 36px 12px; }
.queue-empty .big { font-size: 2.2em; }
</style>
</head>
<body>
<header class="topbar">
<div>
<h1>Freigabe</h1>
<p>Angemeldet als <strong>{{ user }}</strong></p>
</div>
<div class="row">
<a class="linkbtn" href="/admin/dashboard">Dashboard</a>
<a class="linkbtn" href="/admin/article-list">Artikelliste</a>
<a class="linkbtn" href="/admin/schedule">Veröffentlichungsplan</a>
<form method="post" action="/admin/logout">
<button type="submit" class="secondary">Logout</button>
</form>
</div>
</header>
<main class="container">
{% if flash_msg %}
<section class="card flash {{ 'flash-error' if flash_type == 'error' else 'flash-success' }}">
{{ flash_msg }}
</section>
{% endif %}
{% if not articles %}
<section class="card queue-empty">
<div class="big"></div>
<h2>Nichts zu prüfen</h2>
<p class="subtle">
Kein Artikel wartet auf die redaktionelle Freigabe. Neue Artikel erscheinen
hier, sobald die Pipeline gelaufen ist.
</p>
<p><a class="linkbtn" href="/admin/dashboard">Zum Dashboard</a></p>
</section>
{% else %}
<section class="card">
<div class="queue-intro">
<span class="queue-count">{{ articles|length }}</span>
<span>{{ "Artikel wartet" if articles|length == 1 else "Artikel warten" }} auf deine Freigabe</span>
</div>
<p class="subtle">
Lies den Text, dann entscheide: freigeben, neu schreiben oder verwerfen.
Erst die Freigabe legt den WordPress-Beitrag an und bucht den
Veröffentlichungs-Slot. Prüfer und Uhrzeit werden dabei automatisch
festgehalten (Art. 50 Abs. 4 KI-VO).
</p>
{% if articles|length > 1 %}
<ol class="queue-index">
{% for a in articles %}
<li><a href="#artikel-{{ a.id }}">{{ a.title }}</a></li>
{% endfor %}
</ol>
{% endif %}
</section>
{% for a in articles %}
<section class="card review-item" id="artikel-{{ a.id }}">
<div class="review-head">
{% if a.selected_image_proxy_url %}
<div class="review-thumb">
<a href="{{ a.selected_image_url }}" target="_blank" rel="noopener">
<img src="{{ a.selected_image_proxy_url }}" data-fallback-src="{{ a.selected_image_url }}"
alt="Hauptbild" loading="lazy"
onerror="if(!this.dataset.fallbackUsed){this.dataset.fallbackUsed='1';this.src=this.dataset.fallbackSrc;}else{this.classList.add('img-failed');}" />
</a>
</div>
{% endif %}
<div class="review-headtext">
<h2>{{ a.title }}</h2>
<div class="review-facts">
{% if a.relevance_score is not none %}
<span class="badge {{ 'score-hi' if a.relevance_score >= 85 else 'score-mid' }}">Relevanz {{ a.relevance_score }}/100</span>
{% endif %}
<span class="badge">{{ a.word_count or 0 }} Wörter</span>
{% if a.days_old is not none %}<span class="badge">{{ a.days_old }} Tage alt</span>{% endif %}
<span class="subtle">Quelle: {{ a.source_display }}</span>
</div>
{% if a.relevance_reason %}
<p class="subtle">Einschätzung: {{ a.relevance_reason }}</p>
{% endif %}
{% if a.generated_tags %}
<p class="subtle">Tags: {{ a.generated_tags|join(", ") }}</p>
{% endif %}
<p class="subtle">
<a href="{{ a.source_url }}" target="_blank" rel="noopener">Originalartikel öffnen</a>
&nbsp;|&nbsp;
<a href="/admin/articles/{{ a.id }}">Detailseite (Bilder, Rechtliches, Text bearbeiten)</a>
</p>
</div>
</div>
{% if a.preview_html %}
<div class="review-text">{{ a.preview_html|safe }}</div>
{% else %}
<p class="subtle">Kein Rewrite-Text vorhanden — bitte neu schreiben lassen.</p>
{% endif %}
<div class="review-actions">
<form method="post" action="/admin/articles/{{ a.id }}/approve" class="approve">
<input type="hidden" name="redirect_to" value="/admin/freigabe" />
<input name="note" placeholder="Notiz zur Prüfung (optional)" />
<button type="submit" class="btn-approve">✅ Freigeben</button>
</form>
<span class="spacer"></span>
<form method="post" action="/admin/articles/{{ a.id }}/rewrite-run" class="inline">
<input type="hidden" name="redirect_to" value="/admin/freigabe" />
<button type="submit" class="secondary" title="Text mit OpenAI neu schreiben lassen">✏️ Neu schreiben</button>
</form>
<form method="post" action="/admin/articles/{{ a.id }}/transition" class="inline">
<input type="hidden" name="target_status" value="close" />
<input type="hidden" name="note" value="Bei der redaktionellen Prüfung verworfen" />
<input type="hidden" name="redirect_to" value="/admin/freigabe" />
<button type="submit" class="secondary">❌ Verwerfen</button>
</form>
</div>
</section>
{% endfor %}
{% endif %}
</main>
</body>
</html>

View file

@ -23,6 +23,7 @@
</div> </div>
<div class="row"> <div class="row">
<a class="linkbtn" href="/admin/dashboard">Dashboard</a> <a class="linkbtn" href="/admin/dashboard">Dashboard</a>
<a class="linkbtn" href="/admin/freigabe">Freigabe</a>
<a class="linkbtn" href="/admin/connectivity">Connectivity</a> <a class="linkbtn" href="/admin/connectivity">Connectivity</a>
<form method="post" action="/admin/logout"> <form method="post" action="/admin/logout">
<button type="submit" class="secondary">Logout</button> <button type="submit" class="secondary">Logout</button>

View file

@ -65,24 +65,33 @@ class TestArticleWorkflow(unittest.TestCase):
return article.json()["id"] return article.json()["id"]
def test_valid_transition_chain(self) -> None: def test_valid_transition_chain(self) -> None:
"""The chain now runs through the editorial gate: rewrite -> freigabe -> publish."""
article_id = self._create_article() article_id = self._create_article()
t1 = self.client.post(f"/api/articles/{article_id}/transition", json={"target_status": "rewrite"}) t1 = self.client.post(f"/api/articles/{article_id}/transition", json={"target_status": "rewrite"})
self.assertEqual(t1.status_code, 200) self.assertEqual(t1.status_code, 200)
t2 = self.client.post(f"/api/articles/{article_id}/transition", json={"target_status": "publish"}) t2 = self.client.post(f"/api/articles/{article_id}/transition", json={"target_status": "freigabe"})
self.assertEqual(t2.status_code, 200) self.assertEqual(t2.status_code, 200)
t3 = self.client.post(f"/api/articles/{article_id}/transition", json={"target_status": "published"}) t3 = self.client.post(f"/api/articles/{article_id}/transition", json={"target_status": "publish"})
self.assertEqual(t3.status_code, 200) self.assertEqual(t3.status_code, 200)
t4 = self.client.post(f"/api/articles/{article_id}/transition", json={"target_status": "rewrite"}) t4 = self.client.post(f"/api/articles/{article_id}/transition", json={"target_status": "close"})
self.assertEqual(t4.status_code, 200) self.assertEqual(t4.status_code, 200)
final = self.client.get(f"/api/articles/{article_id}") final = self.client.get(f"/api/articles/{article_id}")
self.assertEqual(final.status_code, 200) self.assertEqual(final.status_code, 200)
self.assertEqual(final.json()["item"]["status"], "rewrite") self.assertEqual(final.json()["item"]["status"], "error")
self.assertEqual(final.json()["item"]["status_ui"], "rewrite") self.assertEqual(final.json()["item"]["status_ui"], "close")
def test_rewrite_cannot_jump_straight_to_publish(self) -> None:
"""The old shortcut past the sign-off is gone."""
article_id = self._create_article()
self.client.post(f"/api/articles/{article_id}/transition", json={"target_status": "rewrite"})
jump = self.client.post(f"/api/articles/{article_id}/transition", json={"target_status": "publish"})
self.assertEqual(jump.status_code, 400)
def test_invalid_transition_rejected(self) -> None: def test_invalid_transition_rejected(self) -> None:
article_id = self._create_article() article_id = self._create_article()

View file

@ -15,6 +15,7 @@ from fastapi.testclient import TestClient
from backend.app import config as config_module from backend.app import config as config_module
from backend.app import pipeline as pipeline_module from backend.app import pipeline as pipeline_module
from backend.app.admin_ui import _safe_admin_redirect, _sanitize_preview_html
from backend.app.db import init_db from backend.app.db import init_db
from backend.app.main import app from backend.app.main import app
from backend.app.publisher import _can_publish from backend.app.publisher import _can_publish
@ -315,7 +316,113 @@ class TestDashboardSurfacesQueue(EditorialReviewTestBase):
page = self.client.get("/admin/dashboard") page = self.client.get("/admin/dashboard")
self.assertEqual(page.status_code, 200) self.assertEqual(page.status_code, 200)
self.assertIn("warten auf deine redaktionelle Freigabe", page.text) self.assertIn("warten auf deine redaktionelle Freigabe", page.text)
self.assertIn("status_filter=freigabe", page.text) self.assertIn('href="/admin/freigabe"', page.text)
class TestPreviewSanitizer(unittest.TestCase):
"""The rewrite text is model output over scraped pages — render it filtered."""
def test_keeps_article_markup(self) -> None:
html = _sanitize_preview_html("<h2>Titel</h2><p>Text mit <strong>Betonung</strong></p><ul><li>Punkt</li></ul>")
self.assertIn("<h2>Titel</h2>", html)
self.assertIn("<strong>Betonung</strong>", html)
self.assertIn("<li>Punkt</li>", html)
def test_drops_scripts_and_their_content(self) -> None:
html = _sanitize_preview_html('<p>Text</p><script>alert("x")</script>')
self.assertIn("<p>Text</p>", html)
self.assertNotIn("<script", html)
self.assertNotIn("alert", html)
def test_strips_event_handlers_and_unknown_tags(self) -> None:
html = _sanitize_preview_html('<p onclick="evil()">Hallo</p><iframe src="x"></iframe>')
self.assertNotIn("onclick", html)
self.assertNotIn("<iframe", html)
self.assertIn("Hallo", html)
def test_keeps_only_http_links(self) -> None:
html = _sanitize_preview_html('<p><a href="javascript:evil()">bad</a> <a href="https://ok.de">gut</a></p>')
self.assertNotIn("javascript:", html)
self.assertIn('href="https://ok.de"', html)
def test_plain_text_gets_a_paragraph(self) -> None:
self.assertEqual(_sanitize_preview_html("Nur Text"), "<p>Nur Text</p>")
def test_empty_input(self) -> None:
self.assertEqual(_sanitize_preview_html(None), "")
self.assertEqual(_sanitize_preview_html(" "), "")
class TestSafeRedirect(unittest.TestCase):
def test_accepts_admin_paths(self) -> None:
self.assertEqual(_safe_admin_redirect("/admin/freigabe", "/fallback"), "/admin/freigabe")
def test_rejects_foreign_targets(self) -> None:
for bad in ("https://evil.example/x", "//evil.example", "/admin//evil.example", "", None):
self.assertEqual(_safe_admin_redirect(bad, "/fallback"), "/fallback")
class TestReviewQueuePage(EditorialReviewTestBase):
def _login(self) -> None:
self.client.post(
"/admin/login",
data={"username": "admin", "password": "secret"},
follow_redirects=False,
)
def test_queue_lists_pending_articles_with_all_three_actions(self) -> None:
self._login()
article_id = self._create_article(status="pending_review", rewritten="<h2>Kapitel</h2><p>Inhalt hier</p>")
page = self.client.get("/admin/freigabe")
self.assertEqual(page.status_code, 200)
self.assertIn("Artikel zur Freigabe", page.text) # Titel
self.assertIn("Inhalt hier", page.text) # lesbarer Text
self.assertIn(f"/admin/articles/{article_id}/approve", page.text)
self.assertIn(f"/admin/articles/{article_id}/rewrite-run", page.text)
self.assertIn(f"/admin/articles/{article_id}/transition", page.text)
def test_queue_hides_articles_in_other_states(self) -> None:
self._login()
self._create_article(status="published", rewritten=LONG_TEXT)
page = self.client.get("/admin/freigabe")
self.assertIn("Nichts zu prüfen", page.text)
def test_approving_from_queue_returns_to_the_queue(self) -> None:
self._login()
article_id = self._create_article(status="pending_review", rewritten=LONG_TEXT)
with patch.object(pipeline_module, "reserve_publish_slot", return_value="2026-08-25 09:00:00"), \
patch.object(pipeline_module, "publish_article_draft", return_value=(7, "https://blog/p/7")):
response = self.client.post(
f"/admin/articles/{article_id}/approve",
data={"note": "", "redirect_to": "/admin/freigabe"},
follow_redirects=False,
)
self.assertEqual(response.status_code, 303)
self.assertTrue(response.headers["location"].startswith("/admin/freigabe?"))
self.assertEqual(get_article_by_id(article_id)["status"], "approved")
def test_discarding_from_queue_returns_to_the_queue(self) -> None:
self._login()
article_id = self._create_article(status="pending_review", rewritten=LONG_TEXT)
response = self.client.post(
f"/admin/articles/{article_id}/transition",
data={"target_status": "close", "note": "", "redirect_to": "/admin/freigabe"},
follow_redirects=False,
)
self.assertEqual(response.status_code, 303)
self.assertTrue(response.headers["location"].startswith("/admin/freigabe?"))
self.assertEqual(get_article_by_id(article_id)["status"], "error")
def test_queue_requires_login(self) -> None:
response = self.client.get("/admin/freigabe", follow_redirects=False)
self.assertEqual(response.status_code, 303)
self.assertIn("/admin/login", response.headers["location"])
class TestLegacyArticlesKeepPublishing(EditorialReviewTestBase): class TestLegacyArticlesKeepPublishing(EditorialReviewTestBase):

View file

@ -16,8 +16,8 @@ N8N (2× täglich, 08:00 + 16:00 Uhr)
│ └── Score < 60 Abgelehnt + tägliche Telegram-Liste │ └── Score < 60 Abgelehnt + tägliche Telegram-Liste
└── Pipeline-Zusammenfassung via Telegram └── Pipeline-Zusammenfassung via Telegram
Danach, manuell im Portal (news.vanityontour.de): Danach, manuell im Portal (news.vanityontour.de/admin/freigabe):
Artikel öffnen → lesen → [✅ Redaktionell geprüft & freigeben] Artikel lesen → [✅ Freigeben] / [✏️ Neu schreiben] / [❌ Verwerfen]
├── Stempel: Prüfer + Systemzeit (nicht editierbar) ├── Stempel: Prüfer + Systemzeit (nicht editierbar)
├── Publish-Slot reservieren ├── Publish-Slot reservieren
└── WordPress-Beitrag anlegen (Status "future" zum Slot) └── WordPress-Beitrag anlegen (Status "future" zum Slot)

View file

@ -41,12 +41,11 @@ N8N (2× täglich) → POST /api/n8n/pipeline
└── Status: pending_review ──► KEIN WordPress-Beitrag, KEIN Slot └── Status: pending_review ──► KEIN WordPress-Beitrag, KEIN Slot
Telegram: Info + Link ins Portal Telegram: Info + Link ins Portal
Mensch öffnet den Artikel im Portal, liest ihn Mensch öffnet /admin/freigabe und liest den Artikel
┌─────────────────────────┼──────────────────────────┐ ┌─────────────────────────┼──────────────────────────┐
▼ ▼ ▼ ▼ ▼ ▼
„Redaktionell geprüft „Rewrite ausführen" „Verwerfen" „✅ Freigeben" „✏️ Neu schreiben" „❌ Verwerfen"
& freigeben" (neu schreiben) (Status close)
Stempel: editorial_review_at / _by / _note (Systemzeit, nicht editierbar) Stempel: editorial_review_at / _by / _note (Systemzeit, nicht editierbar)
@ -62,6 +61,19 @@ Wichtig an der Reihenfolge:
der stundenlang auf die Prüfung wartet, darf keinen davon blockieren. Der Slot der stundenlang auf die Prüfung wartet, darf keinen davon blockieren. Der Slot
wird im Moment der Freigabe reserviert, der Scheduler füllt Lücken auf. wird im Moment der Freigabe reserviert, der Scheduler füllt Lücken auf.
## Die Freigabe-Seite
`/admin/freigabe` ist die Arbeitsliste: alle Artikel im Status „Wartet auf
Freigabe", jeweils mit Hauptbild, Relevanz-Score samt Begründung, Tags, Link zum
Originalartikel — und dem **umgeschriebenen Text lesbar gerendert**, nicht als
Markup. Darunter die drei Aktionen. Nach jeder Aktion landet man wieder in der
Liste, sodass eine Warteschlange von oben nach unten abgearbeitet werden kann.
Der Text stammt aus einem Sprachmodell, das mit fremden Webseiten gefüttert
wurde. Für die Anzeige wird er deshalb durch einen Tag-Whitelist-Filter geschickt
(`_sanitize_preview_html`): Absätze, Überschriften, Listen und http(s)-Links
bleiben, alles andere fällt weg.
## Wo der Nachweis liegt ## Wo der Nachweis liegt
| Ort | Inhalt | | Ort | Inhalt |