feat(pipeline): redaktionelle Freigabe vor der Veroeffentlichung

Die Pipeline liess GPT Artikel umschreiben und legte sie direkt als
geplanten WordPress-Beitrag an - ohne dass ein Mensch sie gesehen hat.
Der KI-Hinweis auf dem Blog sagt aber redaktionelle Pruefung zu, und
genau daran haengt die Ausnahme in Art. 50 Abs. 4 KI-VO.

Neuer Status `pending_review` zwischen Rewrite und Publish: Die Pipeline
endet beim Rewrite, ohne WordPress-Beitrag und ohne Publish-Slot. Erst
die Freigabe im Portal stempelt Pruefer und Systemzeit, reserviert den
Slot und legt den Beitrag an.

- Migration: editorial_review_at/_by/_note, Status-CHECK erweitert
- Spalten-Migration laeuft nach den Tabellen-Neubauten erneut, sonst
  verwirft der aeltere no_image-Rebuild die frisch angelegten Spalten
- Jeder Weg nach `approved` stempelt (Button, Statuswechsel, API)
- Jeder maschinelle Rewrite loescht einen alten Stempel
- Telegram: Info mit Portal-Link statt Draft-Meldung, kein Freigabe-Button
- Altbestand bleibt unberuehrt und veroeffentlicht weiter
- EDITORIAL_REVIEW_REQUIRED=false stellt den alten Ablauf wieder her
- 14 neue Tests, docs/KI-VO.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Oliver 2026-08-24 10:35:49 +02:00
parent 682755c6a0
commit a233012887
No known key found for this signature in database
21 changed files with 1151 additions and 64 deletions

View file

@ -1,3 +1,35 @@
## [1.8.0] - 2026-08-24
### ⚖️ Redaktionelle Freigabe vor der Veröffentlichung (Art. 50 Abs. 4 KI-VO)
- Neuer Status **„Wartet auf Freigabe"** (`pending_review`) zwischen Rewrite und Publish
- Die Pipeline endet jetzt beim Rewrite: **kein WordPress-Beitrag und kein Publish-Slot**, bevor ein Mensch den Artikel freigegeben hat
- Neuer Button „✅ Redaktionell geprüft & freigeben" auf der Artikel-Detailseite, mit optionaler Prüfnotiz
- Freigabe stempelt Prüfer und **Systemzeit** in `editorial_review_at` / `_by` / `_note` — nicht editierbar, zusätzlich im Audit-Trail (`review_events`)
- Erst die Freigabe reserviert den Slot und legt den WordPress-Beitrag an (Status `future`)
- Schlägt WordPress dabei fehl, fällt der Artikel mit freigegebenem Slot zurück in die Warteschlange — Freigabe einfach wiederholen
- Kein Schlupfloch: auch der manuelle Statuswechsel nach `publish` (Admin-UI und API) stempelt die Freigabe
- Altbestand bleibt unberührt: bereits freigegebene/veröffentlichte Artikel brauchen keinen Stempel
### 📨 Telegram
- Neue Meldung „📝 Neuer Artikel wartet auf Freigabe" mit Deep-Link ins Portal, bewusst **ohne** Freigabe-Button
- Neue Bestätigung „✅ Freigegeben und eingeplant" mit Prüfer und Slot
- Relevanz-Warnung enthält zusätzlich den Portal-Link
- Pipeline-Zusammenfassung und `/status` zeigen die Zahl der wartenden Artikel
### 🖥️ Admin-UI
- Dashboard-Banner mit Anzahl der Artikel, die auf Freigabe warten, inkl. Direktfilter
- Klartext-Bezeichnungen für alle Status statt interner Kürzel
- Publish-Bereitschaft weist „Redaktionelle Freigabe fehlt" aus
### 🔧 Technisch
- Migration: Spalten `editorial_review_at/_by/_note`, Status-CHECK um `pending_review` erweitert
- Spalten-Migration wird nach den Tabellen-Neubauten erneut angewandt (der ältere `no_image`-Rebuild hat frisch angelegte Spalten sonst wieder verworfen)
- Neue Konfiguration `EDITORIAL_REVIEW_REQUIRED` (Default `true`) und `PORTAL_BASE_URL`
- Neue Doku `docs/KI-VO.md`; `docs/AUTOMATION.md` an den neuen Ablauf angepasst
- 12 neue Tests in `backend/tests/test_editorial_review.py`
---
## [1.7.1] - 2025-08-24
### ✨ Security angepasst

View file

@ -32,6 +32,7 @@ Details: `docs/PROJECT_PLAN.md`
- Projektplan: `docs/PROJECT_PLAN.md`
- ToDo-Liste: `docs/TODO.md`
- Quell- und Lizenzpolicy: `docs/SOURCE_POLICY.md`
- KI-Verordnung / redaktionelle Freigabe: `docs/KI-VO.md`
- Wiki Home: `docs/wiki/Home.md`
## Lokale Entwicklung (Legacy-Code)

View file

@ -47,3 +47,13 @@ PIPELINE_PUBLISH_HOURS=9,12,15,18
PIPELINE_PUBLISH_START_HOUR=9
PIPELINE_PUBLISH_END_HOUR=19
PIPELINE_PUBLISH_MIN_GAP_HOURS=3
# ─── Redaktionelle Freigabe (Art. 50 Abs. 4 KI-VO) ───────────────────────────
# true: Die Pipeline stoppt nach dem Rewrite. Der Artikel wartet im Portal auf
# die Freigabe durch einen Menschen; erst danach entstehen WordPress-
# Beitrag und Veröffentlichungs-Slot.
# false: alter, vollautomatischer Ablauf (dann trägt der KI-Hinweis auf dem Blog
# die Aussage "redaktionell geprüft" zu Unrecht).
EDITORIAL_REVIEW_REQUIRED=true
# Basis-URL des Portals für die Deep-Links in den Telegram-Meldungen
PORTAL_BASE_URL=https://news.vanityontour.de

View file

@ -7,7 +7,7 @@ import socket
import ssl
import time
from urllib.parse import urlparse
from urllib.parse import urlencode
from urllib.parse import urlencode, quote_plus
from urllib.request import Request as UrlRequest, urlopen
from fastapi import APIRouter, Form, Request
@ -17,6 +17,7 @@ from fastapi.templating import Jinja2Templates
from .auth import create_session_token, verify_credentials, verify_session_token
from .config import get_settings
from .ingestion import run_ingestion
from .pipeline import approve_article, post_rewrite_status
from .policy import evaluate_source_policy
from .publisher import enqueue_publish, run_publisher
from .relevance import article_age_days, article_relevance
@ -28,6 +29,7 @@ from .repositories import (
SourceUpdate,
delete_feed,
delete_source,
clear_article_editorial_review,
create_feed,
create_source,
get_article_by_id,
@ -46,17 +48,25 @@ from .repositories import (
update_article_status,
ArticleUpsert,
)
from .workflow import ALLOWED_UI_TRANSITIONS, UI_STATUSES, internal_to_ui_status, ui_to_internal_status
from .workflow import (
ALLOWED_UI_TRANSITIONS,
UI_STATUS_LABELS,
UI_STATUSES,
internal_to_ui_status,
ui_to_internal_status,
)
settings = get_settings()
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": ("publish", "close"),
"rewrite": ("freigabe", "publish", "close"),
"freigabe": ("publish", "rewrite", "close"),
"publish": ("published", "close"),
"published": ("rewrite", "close"),
"close": ("rewrite",),
"no_image": ("rewrite", "close"),
}
IMAGE_PROXY_USER_AGENT = "rss-news-admin/1.0"
_UNSET = object()
@ -172,8 +182,16 @@ def _build_image_entries(article: dict, extraction: dict, meta: dict) -> list[di
def _publish_readiness(article: dict, meta: dict) -> tuple[bool, list[str]]:
reasons: list[str] = []
if internal_to_ui_status(article.get("status")) not in {"publish", "published"}:
status_ui = internal_to_ui_status(article.get("status"))
if status_ui not in {"publish", "published"}:
reasons.append("Status ist nicht 'publish'")
# Altbestand (bereits veroeffentlicht) hat keinen Stempel und braucht keinen.
if (
settings.editorial_review_required
and status_ui != "published"
and not (article.get("editorial_review_at") or "")
):
reasons.append("Redaktionelle Freigabe fehlt")
image_review = meta.get("image_review") if isinstance(meta.get("image_review"), dict) else {}
selected_image = image_review.get("selected_url") if isinstance(image_review.get("selected_url"), str) else None
if not selected_image:
@ -513,7 +531,9 @@ def admin_dashboard(request: Request):
"publish_jobs": publish_jobs,
"articles": articles,
"status_options": list(UI_STATUSES),
"status_labels": UI_STATUS_LABELS,
"allowed_transitions": ALLOWED_TRANSITIONS,
"pending_review_count": len(list_articles(limit=500, status_filter="pending_review")),
"status_filter": status_filter,
"flash_msg": request.query_params.get("msg", ""),
"flash_type": request.query_params.get("type", "success"),
@ -588,6 +608,8 @@ def admin_article_detail(request: Request, article_id: int):
"feed": feed,
"checklist": checklist,
"allowed_transitions": ALLOWED_TRANSITIONS.get(article.get("status_ui"), ()),
"status_labels": UI_STATUS_LABELS,
"editorial_review_required": settings.editorial_review_required,
"flash_msg": request.query_params.get("msg", ""),
"flash_type": request.query_params.get("type", "success"),
},
@ -837,16 +859,23 @@ def admin_rewrite_run(request: Request, article_id: int):
article = get_article_by_id(article_id)
if not article:
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"}:
return _dashboard_redirect(msg=f"Rewrite nur aus new/rewrite fuer Artikel #{article_id}", msg_type="error")
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")
try:
rewritten = rewrite_article_text(article)
tags = generate_article_tags(article, rewritten_text=rewritten)
except Exception as exc:
return _dashboard_redirect(msg=f"Rewrite fehlgeschlagen fuer Artikel #{article_id}: {exc}", msg_type="error")
merged_meta = merge_generated_tags(article.get("meta_json"), tags)
_upsert_article_from_existing(article, content_rewritten=rewritten, status="approved", meta_json=merged_meta)
return _dashboard_redirect(msg=f"Rewrite fertig fuer Artikel #{article_id} -> publish")
new_status = post_rewrite_status()
_upsert_article_from_existing(article, content_rewritten=rewritten, status=new_status, meta_json=merged_meta)
if new_status == "pending_review":
clear_article_editorial_review(article_id)
target = "Freigabe" if new_status == "pending_review" else "publish"
return RedirectResponse(
url=f"/admin/articles/{article_id}?msg={quote_plus(f'Rewrite fertig -> {target}')}&type=success",
status_code=303,
)
@router.post("/admin/rewrite/run")
@ -868,7 +897,10 @@ def admin_rewrite_run_batch(request: Request, max_jobs: str = Form("10")):
rewritten = rewrite_article_text(article)
tags = generate_article_tags(article, rewritten_text=rewritten)
merged_meta = merge_generated_tags(article.get("meta_json"), tags)
_upsert_article_from_existing(article, content_rewritten=rewritten, status="approved", meta_json=merged_meta)
new_status = post_rewrite_status()
_upsert_article_from_existing(article, content_rewritten=rewritten, status=new_status, meta_json=merged_meta)
if new_status == "pending_review":
clear_article_editorial_review(int(article["id"]))
success += 1
except Exception:
failed += 1
@ -916,6 +948,37 @@ def admin_reopen_article(request: Request, article_id: int):
)
@router.post("/admin/articles/{article_id}/approve")
def admin_approve_article(request: Request, article_id: int, note: str = Form("")):
"""Editorial sign-off: a person read the article and takes responsibility.
Stamps reviewer and system time, then hands the article to WordPress and the
scheduler. Nothing about the timestamp is user-editable that is the point.
"""
user = _admin_user(request)
if not user:
return RedirectResponse(url="/admin/login", status_code=303)
try:
result = approve_article(article_id, actor=user, note=note.strip() or None)
except ValueError as exc:
return RedirectResponse(
url=f"/admin/articles/{article_id}?msg={quote_plus(str(exc))}&type=error",
status_code=303,
)
except Exception as exc:
return RedirectResponse(
url=f"/admin/articles/{article_id}?msg={quote_plus(f'Freigabe fehlgeschlagen: {exc}')}&type=error",
status_code=303,
)
slot = result.get("scheduled_publish_at") or "-"
return RedirectResponse(
url=f"/admin/articles/{article_id}?msg={quote_plus(f'Freigegeben. Veroeffentlichung geplant fuer {slot}.')}&type=success",
status_code=303,
)
@router.post("/admin/articles/{article_id}/transition")
def admin_transition_article(request: Request, article_id: int, target_status: str = Form(...), note: str = Form("")):
user = _admin_user(request)
@ -928,6 +991,11 @@ def admin_transition_article(request: Request, article_id: int, target_status: s
target_internal = ui_to_internal_status(target_status)
target_ui = internal_to_ui_status(target_internal)
if target_ui in ALLOWED_TRANSITIONS.get(current_ui, ()):
# Setting an article to `publish` by hand IS the editorial sign-off.
# Route it through the same door so it cannot slip into WordPress
# unstamped and unscheduled.
if target_ui == "publish" and not (article.get("editorial_review_at") or ""):
return admin_approve_article(request, article_id, note=note)
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 _dashboard_redirect(msg=f"Ungueltiger Statuswechsel fuer Artikel #{article_id}", msg_type="error")

View file

@ -58,6 +58,15 @@ class Settings(BaseSettings):
pipeline_min_words_rewritten: int = 150 # minimum words in rewritten content (else reject)
pipeline_max_article_age_days: int = 7 # skip articles older than N days during ingestion (0 = no limit)
# Redaktionelle Freigabe (Art. 50 Abs. 4 KI-VO)
# Ist das Gate aktiv, endet die Pipeline beim Rewrite: der Artikel wartet im
# Portal auf die Freigabe durch einen Menschen. Erst danach entsteht der
# WordPress-Beitrag und der Publish-Slot. Auf False laeuft der alte,
# vollautomatische Ablauf.
editorial_review_required: bool = True
# Basis-URL des Portals fuer die Deep-Links in den Telegram-Meldungen.
portal_base_url: str = "https://news.vanityontour.de"
@lru_cache(maxsize=1)
def get_settings() -> Settings:

View file

@ -110,8 +110,11 @@ def init_db() -> None:
publish_last_error TEXT,
published_to_wp_at TEXT,
word_count INTEGER DEFAULT 0,
status TEXT NOT NULL DEFAULT 'new' CHECK (status IN ('new', 'rewrite', 'review', 'approved', 'published', 'error', 'no_image')),
status TEXT NOT NULL DEFAULT 'new' CHECK (status IN ('new', 'rewrite', 'review', 'pending_review', 'approved', 'published', 'error', 'no_image')),
meta_json TEXT,
editorial_review_at TEXT,
editorial_review_by TEXT,
editorial_review_note TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY(feed_id) REFERENCES feeds(id) ON DELETE SET NULL,
@ -191,10 +194,26 @@ def init_db() -> None:
"publish_attempts": "ALTER TABLE articles ADD COLUMN publish_attempts INTEGER NOT NULL DEFAULT 0",
"publish_last_error": "ALTER TABLE articles ADD COLUMN publish_last_error TEXT",
"published_to_wp_at": "ALTER TABLE articles ADD COLUMN published_to_wp_at TEXT",
# Nachweis der menschlichen redaktionellen Kontrolle (Art. 50 Abs. 4 KI-VO).
"editorial_review_at": "ALTER TABLE articles ADD COLUMN editorial_review_at TEXT",
"editorial_review_by": "ALTER TABLE articles ADD COLUMN editorial_review_by TEXT",
"editorial_review_note": "ALTER TABLE articles ADD COLUMN editorial_review_note TEXT",
}
for column, ddl in migration_columns.items():
if column not in existing_columns:
conn.execute(ddl)
def _add_missing_columns() -> None:
"""(Re-)apply the column migrations against the current table.
Must be callable more than once: the CHECK-constraint rebuilds below
recreate `articles` from a fixed column list, so a column added here
can disappear again and has to be re-added afterwards.
"""
present = {
row["name"] for row in conn.execute("PRAGMA table_info(articles)").fetchall()
}
for column, ddl in migration_columns.items():
if column not in present:
conn.execute(ddl)
_add_missing_columns()
# Migration: add 'no_image' to the status CHECK constraint if not present.
# SQLite cannot modify CHECK constraints in-place, so we recreate the table.
@ -279,6 +298,99 @@ def init_db() -> None:
"""
)
# The v2 rebuild above copies a fixed column list, so anything added by
# _add_missing_columns() before it is gone again. Re-add before v3 reads
# those columns.
_add_missing_columns()
# Migration: add 'pending_review' to the status CHECK constraint. Same
# recreate-the-table dance as above, SQLite cannot alter a CHECK in place.
table_sql_row = conn.execute(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='articles'"
).fetchone()
if table_sql_row and "'pending_review'" not in (table_sql_row["sql"] or ""):
conn.executescript(
"""
PRAGMA foreign_keys=OFF;
CREATE TABLE articles_v3 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
feed_id INTEGER,
source_article_id TEXT,
source_hash TEXT,
title TEXT NOT NULL,
source_url TEXT NOT NULL,
canonical_url TEXT,
published_at TEXT,
author TEXT,
summary TEXT,
content_raw TEXT,
content_rewritten TEXT,
image_urls_json TEXT,
press_contact TEXT,
source_name_snapshot TEXT,
source_terms_url_snapshot TEXT,
source_license_name_snapshot TEXT,
legal_checked INTEGER NOT NULL DEFAULT 0,
legal_checked_at TEXT,
legal_note TEXT,
wp_post_id INTEGER,
wp_post_url TEXT,
publish_attempts INTEGER NOT NULL DEFAULT 0,
publish_last_error TEXT,
published_to_wp_at TEXT,
word_count INTEGER DEFAULT 0,
status TEXT NOT NULL DEFAULT 'new' CHECK (status IN ('new', 'rewrite', 'review', 'pending_review', 'approved', 'published', 'error', 'no_image')),
meta_json TEXT,
relevance_score INTEGER,
scheduled_publish_at TEXT,
editorial_review_at TEXT,
editorial_review_by TEXT,
editorial_review_note TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY(feed_id) REFERENCES feeds(id) ON DELETE SET NULL,
UNIQUE(source_url)
);
INSERT INTO articles_v3 SELECT
id, feed_id, source_article_id, source_hash, title, source_url,
canonical_url, published_at, author, summary, content_raw,
content_rewritten, image_urls_json, press_contact,
source_name_snapshot, source_terms_url_snapshot, source_license_name_snapshot,
legal_checked, legal_checked_at, legal_note,
wp_post_id, wp_post_url, publish_attempts, publish_last_error,
published_to_wp_at, word_count, status, meta_json,
relevance_score, scheduled_publish_at,
editorial_review_at, editorial_review_by, editorial_review_note,
created_at, updated_at
FROM articles;
DROP TABLE articles;
ALTER TABLE articles_v3 RENAME TO articles;
CREATE INDEX IF NOT EXISTS idx_articles_source_article_id ON articles(source_article_id);
CREATE INDEX IF NOT EXISTS idx_articles_source_hash ON articles(source_hash);
CREATE UNIQUE INDEX IF NOT EXISTS uq_articles_feed_source_article_id
ON articles(feed_id, source_article_id)
WHERE source_article_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uq_articles_source_hash
ON articles(source_hash)
WHERE source_hash IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_articles_status ON articles(status);
CREATE INDEX IF NOT EXISTS idx_articles_published_at ON articles(published_at);
CREATE TRIGGER IF NOT EXISTS trg_articles_updated_at
AFTER UPDATE ON articles
FOR EACH ROW
BEGIN
UPDATE articles SET updated_at = datetime('now') WHERE id = OLD.id;
END;
PRAGMA foreign_keys=ON;
"""
)
table_rows = conn.execute(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'publish_jobs'"
).fetchall()

View file

@ -17,7 +17,7 @@ from .auth import create_session_token, verify_credentials, verify_session_token
from .config import get_settings
from .db import init_db
from .ingestion import run_ingestion
from .pipeline import run_auto_pipeline
from .pipeline import post_rewrite_status, run_auto_pipeline
from .policy import evaluate_source_policy, is_source_allowed
from .publisher import enqueue_publish, run_publisher
from .relevance import article_age_days, article_relevance
@ -41,6 +41,8 @@ from .repositories import (
list_feeds as repo_list_feeds,
list_runs,
list_sources as repo_list_sources,
clear_article_editorial_review,
set_article_editorial_review,
set_article_legal_review,
update_article_status,
upsert_article as repo_upsert_article,
@ -503,6 +505,11 @@ def api_article_transition(article_id: int, payload: ArticleTransitionRequest, u
detail=f"Ungueltiger Statuswechsel: {current_ui} -> {target_ui}",
)
# Reaching `publish` means a person signed the article off. Stamp that fact
# (Art. 50 Abs. 4 KI-VO) so the API cannot bypass the editorial gate.
if target_ui == "publish" and not (article.get("editorial_review_at") or ""):
set_article_editorial_review(article_id, actor=username, note=payload.note)
updated = update_article_status(article_id, target_internal, actor=username, note=payload.note)
if not updated:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Artikel nicht gefunden")
@ -552,11 +559,20 @@ def api_article_rewrite_run(article_id: int, username: str = Depends(require_aut
publish_last_error=article.get("publish_last_error"),
published_to_wp_at=article.get("published_to_wp_at"),
word_count=len(rewritten.split()),
status="approved",
status=post_rewrite_status(),
meta_json=merged_meta,
)
)
return {"ok": True, "id": article_id, "status": "publish", "tags": tags}
new_status = post_rewrite_status()
if new_status == "pending_review":
# New text, so any earlier sign-off no longer applies.
clear_article_editorial_review(article_id)
return {
"ok": True,
"id": article_id,
"status": internal_to_ui_status(new_status),
"tags": tags,
}
@app.post("/api/articles/{article_id}/legal-review")

View file

@ -25,7 +25,9 @@ from .publisher import enqueue_publish, run_publisher
from .repositories import (
ArticleUpsert,
get_article_by_id,
clear_article_editorial_review,
list_articles,
set_article_editorial_review,
set_article_image_decision,
update_article_status,
upsert_article as repo_upsert_article,
@ -42,6 +44,7 @@ class PipelineStats:
ingested: int = 0
processed: int = 0
drafts_created: int = 0
pending_review: int = 0
rejected: int = 0
quality_gate_rejected: int = 0
warnings: int = 0
@ -107,8 +110,35 @@ def _store_relevance(article_id: int, relevance: dict[str, Any]) -> None:
)
def _do_rewrite_and_draft(article: dict[str, Any]) -> tuple[int, str | None]:
"""Rewrite article and create WP draft. Returns (wp_post_id, wp_post_url)."""
def editorial_gate_active() -> bool:
"""Is the human sign-off required before an article may be published?"""
return bool(getattr(get_settings(), "editorial_review_required", True))
def post_rewrite_status() -> str:
"""Status an article gets right after a rewrite.
With the gate active it waits for a person; without it, it goes straight to
`approved` the way it did before. Every rewrite path in the app uses this so
none of them can quietly become a back door into publishing.
"""
return "pending_review" if editorial_gate_active() else "approved"
def _do_rewrite_and_draft(
article: dict[str, Any],
*,
create_wp_draft: bool = True,
) -> tuple[int | None, str | None]:
"""Rewrite article and (optionally) create the WP draft.
With `create_wp_draft=False` the article stops at status `pending_review`:
no publish slot, no WordPress post. That is the editorial gate a human
signs the article off in the portal first (Art. 50 Abs. 4 KI-VO), and only
then does `approve_article()` finish the job.
Returns (wp_post_id, wp_post_url), both None when the draft was skipped.
"""
article_id = int(article["id"])
settings = get_settings()
@ -175,7 +205,7 @@ def _do_rewrite_and_draft(article: dict[str, Any]) -> tuple[int, str | None]:
publish_last_error=article.get("publish_last_error"),
published_to_wp_at=article.get("published_to_wp_at"),
word_count=len(rewritten.split()),
status="approved",
status="approved" if create_wp_draft else "pending_review",
meta_json=merged_meta,
)
)
@ -185,6 +215,15 @@ def _do_rewrite_and_draft(article: dict[str, Any]) -> tuple[int, str | None]:
if not fresh:
raise RuntimeError(f"Artikel #{article_id} nach Rewrite nicht gefunden")
if not create_wp_draft:
# The text is new, so an older sign-off no longer covers it.
clear_article_editorial_review(article_id)
logger.info(
"_do_rewrite_and_draft #%d: Rewrite gespeichert, wartet auf redaktionelle Freigabe",
article_id,
)
return None, None
# Ensure a publish slot is reserved — reserve one now if not yet set
if not fresh.get("scheduled_publish_at"):
from .scheduler import reserve_publish_slot
@ -261,6 +300,7 @@ def run_auto_pipeline(trigger: str = "auto") -> dict[str, Any]:
"ingested": stats.ingested,
"processed": stats.processed,
"drafts_created": stats.drafts_created,
"pending_review": stats.pending_review,
"rejected": stats.rejected,
"quality_gate_rejected": stats.quality_gate_rejected,
"no_image": stats.no_image,
@ -349,25 +389,39 @@ def _process_article(article: dict[str, Any], stats: PipelineStats, settings: An
logger.warning("Telegram warning für #%d fehlgeschlagen: %s", article_id, exc)
else:
# Auto-process: rewrite + WP draft
# Auto-process: rewrite, then either park the article for the editorial
# sign-off or (gate disabled) go straight to the WP draft as before.
gate = editorial_gate_active()
try:
# Reserve publish slot FIRST so it's available when WP draft is created
slot = reserve_publish_slot(article_id)
# Without the gate the slot must exist before the WP draft is built.
# With the gate the slot is reserved at approval time instead, so a
# pending article does not sit on a publish slot for hours.
slot: str | None = None
if not gate:
slot = reserve_publish_slot(article_id)
# Reload article to get updated image_review + scheduled_publish_at
fresh = get_article_by_id(article_id)
if not fresh:
return
wp_post_id, wp_post_url = _do_rewrite_and_draft(fresh)
stats.drafts_created += 1
_do_rewrite_and_draft(fresh, create_wp_draft=not gate)
# Reload for notification
final = get_article_by_id(article_id)
if final:
try:
tg.notify_new_draft(final, score=score, suggested_publish_at=slot)
except Exception as exc:
logger.warning("Telegram draft-Benachrichtigung für #%d fehlgeschlagen: %s", article_id, exc)
if gate:
stats.pending_review += 1
if final:
try:
tg.notify_pending_review(final, score=score)
except Exception as exc:
logger.warning("Telegram Freigabe-Hinweis für #%d fehlgeschlagen: %s", article_id, exc)
else:
stats.drafts_created += 1
if final:
try:
tg.notify_new_draft(final, score=score, suggested_publish_at=slot)
except Exception as exc:
logger.warning("Telegram draft-Benachrichtigung für #%d fehlgeschlagen: %s", article_id, exc)
except ValueError as exc:
# Quality gate rejection (too short etc.) — status already set in _do_rewrite_and_draft
@ -411,13 +465,92 @@ def _process_article(article: dict[str, Any], stats: PipelineStats, settings: An
# ---------------------------------------------------------------------------
def rewrite_and_update_draft(article_id: int) -> None:
"""Rewrite article and update the existing WP draft."""
"""Rewrite article and update the existing WP draft.
An article that already lives in WordPress keeps its post updated. One that
never got there (because it is waiting for the editorial sign-off) stays in
the review queue and must be approved again after the rewrite.
"""
article = get_article_by_id(article_id)
if not article:
raise RuntimeError(f"Artikel #{article_id} nicht gefunden")
_auto_select_image(article)
fresh = get_article_by_id(article_id)
_do_rewrite_and_draft(fresh)
gate = editorial_gate_active()
keep_wp_in_sync = bool(fresh.get("wp_post_id")) or not gate
_do_rewrite_and_draft(fresh, create_wp_draft=keep_wp_in_sync)
def approve_article(article_id: int, actor: str, note: str | None = None) -> dict[str, Any]:
"""Record the human editorial sign-off, then publish to WP and schedule it.
This is the single door to `approved`: it stamps who approved and when
(Art. 50 Abs. 4 KI-VO), reserves the publish slot and creates the scheduled
WordPress post. If WordPress fails, the article falls back into the review
queue with its slot released, so the approval can simply be repeated.
"""
from . import telegram_bot as tg
article = get_article_by_id(article_id)
if not article:
raise RuntimeError(f"Artikel #{article_id} nicht gefunden")
if not (article.get("content_rewritten") or "").strip():
raise ValueError("Kein Rewrite-Text vorhanden — Artikel kann nicht freigegeben werden")
if not selected_image_exists(article):
raise ValueError("Kein Hauptbild ausgewählt — Artikel kann nicht freigegeben werden")
reviewed_at = set_article_editorial_review(article_id, actor=actor, note=note)
update_article_status(
article_id,
"approved",
actor=actor,
note=note or "Redaktionell geprüft und freigegeben",
decision="editorial_approval",
)
try:
slot = reserve_publish_slot(article_id)
fresh = get_article_by_id(article_id)
if not fresh:
raise RuntimeError(f"Artikel #{article_id} nach Freigabe nicht gefunden")
wp_post_id, wp_post_url = publish_article_draft(fresh)
except Exception as exc:
from .scheduler import release_publish_slot
release_publish_slot(article_id)
update_article_status(
article_id,
"pending_review",
actor="system",
note=f"WordPress-Fehler nach Freigabe: {exc}",
)
logger.error("Freigabe von #%d fehlgeschlagen: %s", article_id, exc)
raise
from .repositories import mark_article_publish_result
mark_article_publish_result(
article_id,
wp_post_id=wp_post_id,
wp_post_url=wp_post_url,
error=None,
increment_attempts=True,
set_published_status=False,
)
final = get_article_by_id(article_id) or {}
try:
tg.notify_approved(final, scheduled_at=slot)
except Exception as exc:
logger.warning("Telegram Freigabe-Bestätigung für #%d fehlgeschlagen: %s", article_id, exc)
return {
"article_id": article_id,
"reviewed_at": reviewed_at,
"reviewed_by": actor,
"scheduled_publish_at": slot,
"wp_post_id": wp_post_id,
"wp_post_url": wp_post_url,
}
def discard_article(article_id: int) -> None:
@ -463,11 +596,21 @@ def override_rejected_article(article_id: int) -> None:
except Exception:
score = 0
gate = editorial_gate_active()
if gate:
# "Trotzdem verarbeiten" means process it, not publish it — the article
# still has to pass the editorial sign-off in the portal.
_do_rewrite_and_draft(fresh, create_wp_draft=False)
final = get_article_by_id(article_id)
if final:
tg.notify_pending_review(final, score=score)
return
# Reserve publish slot FIRST so it's in the DB when WP draft is created
slot = reserve_publish_slot(article_id)
fresh = get_article_by_id(article_id)
wp_post_id, wp_post_url = _do_rewrite_and_draft(fresh)
_do_rewrite_and_draft(fresh)
final = get_article_by_id(article_id)
if final:
@ -503,6 +646,7 @@ def get_pipeline_status_text() -> str:
"""Return a text summary of current pipeline state."""
from .repositories import list_articles as _list
new_count = len(_list(limit=500, status_filter="new"))
pending_count = len(_list(limit=500, status_filter="pending_review"))
approved_count = len(_list(limit=500, status_filter="approved"))
published_count = len(_list(limit=500, status_filter="published"))
error_count = len(_list(limit=500, status_filter="error"))
@ -510,6 +654,7 @@ def get_pipeline_status_text() -> str:
return (
f"📊 <b>Pipeline-Status</b>\n"
f"🆕 Neu / wartend: {new_count}\n"
f"📝 Wartet auf Freigabe: {pending_count}\n"
f"✅ Draft / freigegeben: {approved_count}\n"
f"📢 Veröffentlicht: {published_count}\n"
f"🚫 Fehler / abgelehnt: {error_count}"

View file

@ -322,7 +322,8 @@ def get_article_by_id(article_id: int) -> dict[str, Any] | None:
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.word_count, a.status, a.meta_json, a.created_at, a.updated_at,
a.scheduled_publish_at
a.scheduled_publish_at,
a.editorial_review_at, a.editorial_review_by, a.editorial_review_note
FROM articles a
WHERE a.id = ?
""",
@ -414,6 +415,55 @@ def set_article_legal_review(article_id: int, approved: bool, note: str | None,
return True
def set_article_editorial_review(article_id: int, actor: str, note: str | None = None) -> str | None:
"""Stamp the human editorial sign-off on an article.
This is the evidence for the exemption in Art. 50 (4) KI-VO: a named person
read the AI-rewritten text and takes editorial responsibility for it. The
timestamp is set by the system, never by hand, so it cannot be backdated in
the UI. Returns the ISO timestamp, or None if the article is unknown.
"""
article = get_article_by_id(article_id)
if not article:
return None
reviewed_at = datetime.now(timezone.utc).isoformat()
event = {
"timestamp": reviewed_at,
"event": "editorial_review",
"actor": actor,
"note": note,
}
merged_meta = _merge_review_event(article.get("meta_json"), event)
with get_conn() as conn:
conn.execute(
"""
UPDATE articles
SET editorial_review_at = ?, editorial_review_by = ?, editorial_review_note = ?, meta_json = ?
WHERE id = ?
""",
(reviewed_at, actor, note, merged_meta, article_id),
)
return reviewed_at
def clear_article_editorial_review(article_id: int) -> None:
"""Drop the sign-off because the text changed after it was given.
A stamp says "a person read *this* text". Once a machine rewrite replaces the
text, that is no longer true, so the article has to be read again.
"""
with get_conn() as conn:
conn.execute(
"""
UPDATE articles
SET editorial_review_at = NULL, editorial_review_by = NULL, editorial_review_note = NULL
WHERE id = ?
""",
(article_id,),
)
def set_article_image_decision(article_id: int, image_url: str, action: str, actor: str | None = None) -> bool:
article = get_article_by_id(article_id)
if not article:
@ -783,7 +833,8 @@ def list_articles_page(
select = """
SELECT a.id, a.title, a.status, a.published_at, a.summary, a.content_raw,
a.meta_json, a.wp_post_id, a.wp_post_url, a.scheduled_publish_at,
a.word_count, f.name AS feed_name
a.word_count, a.editorial_review_at, a.editorial_review_by,
f.name AS feed_name
FROM articles a
LEFT JOIN feeds f ON f.id = a.feed_id
"""
@ -828,7 +879,8 @@ def list_articles(limit: int = 100, status_filter: str | None = None) -> list[di
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.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.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,
a.editorial_review_at, a.editorial_review_by, a.editorial_review_note
FROM articles a
LEFT JOIN feeds f ON f.id = a.feed_id
WHERE a.status = ?
@ -844,7 +896,8 @@ def list_articles(limit: int = 100, status_filter: str | None = None) -> list[di
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.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.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,
a.editorial_review_at, a.editorial_review_by, a.editorial_review_note
FROM articles a
LEFT JOIN feeds f ON f.id = a.feed_id
ORDER BY a.id DESC

View file

@ -220,6 +220,74 @@ def notify_new_draft(
send_message(text, reply_markup=keyboard)
def _portal_article_url(article_id: Any) -> str:
base = (get_settings().portal_base_url or "").rstrip("/")
return f"{base}/admin/articles/{article_id}"
def _article_image_url(article: dict[str, Any]) -> str | None:
try:
meta = json.loads(article.get("meta_json") or "{}")
except Exception:
return None
image_review = meta.get("image_review") or {}
if isinstance(image_review, dict) and image_review.get("selected_url"):
return image_review.get("selected_url")
image_sel = (meta.get("extraction") or {}).get("image_selection") or {}
return image_sel.get("primary")
def notify_pending_review(article: dict[str, Any], score: int) -> None:
"""Announce an article that waits for the human editorial sign-off.
Deliberately carries no approve button: the whole point of the gate is that
a person opened the article and read it. The buttons live on the article
page in the portal, one tap away via the link below.
"""
title = (article.get("title") or "Ohne Titel").strip()
art_id = article.get("id")
tags_str = _format_tags(article.get("meta_json"))
words = article.get("word_count") or 0
text_parts = [
"📝 <b>Neuer Artikel wartet auf Freigabe</b>",
f"📰 <b>{title}</b>",
f"{_score_emoji(score)} Relevanz-Score: <b>{score}/100</b>",
f"📄 {words} Wörter",
]
if tags_str:
text_parts.append(f"🏷 {tags_str}")
text_parts.append(f'🔗 <a href="{_portal_article_url(art_id)}">Im Portal prüfen und freigeben</a>')
text_parts.append(
"<i>Erst nach der Freigabe geht der Beitrag nach WordPress und wird eingeplant.</i>"
)
text = "\n".join(text_parts)
image_url = _article_image_url(article)
if image_url:
send_photo_message(image_url, caption=text)
else:
send_message(text)
def notify_approved(article: dict[str, Any], scheduled_at: str | None = None) -> None:
"""Confirm that an approved article reached WordPress and got a slot."""
title = (article.get("title") or "Ohne Titel").strip()
wp_url = article.get("wp_post_url") or ""
reviewer = article.get("editorial_review_by") or "?"
parts = [
"✅ <b>Freigegeben und eingeplant</b>",
f"📰 <b>{title}</b>",
f"👤 Geprüft von: <b>{reviewer}</b>",
]
if scheduled_at:
parts.append(f"📅 Veröffentlichung: <b>{scheduled_at}</b>")
if wp_url:
parts.append(f'🔗 <a href="{wp_url}">Beitrag in WordPress</a>')
send_message("\n".join(parts))
def notify_relevance_warning(article: dict[str, Any], score: int, reason: str) -> None:
"""Send Telegram warning for borderline articles (score between warn and auto thresholds)."""
title = (article.get("title") or "Ohne Titel").strip()
@ -231,7 +299,8 @@ def notify_relevance_warning(article: dict[str, Any], score: int, reason: str) -
f"📰 <b>{title}</b>\n"
f"{_score_emoji(score)} Score: <b>{score}/100</b>\n"
f"💬 {reason}\n"
f'🔗 <a href="{source_url}">Originalartikel</a>'
f'🔗 <a href="{source_url}">Originalartikel</a>\n'
f'📋 <a href="{_portal_article_url(art_id)}">Im Portal ansehen</a>'
)
keyboard = _inline_keyboard([
[
@ -288,6 +357,7 @@ def notify_pipeline_done(stats: dict[str, Any]) -> None:
ingested = stats.get("ingested", 0)
processed = stats.get("processed", 0)
drafts = stats.get("drafts_created", 0)
pending_review = stats.get("pending_review", 0)
rejected = stats.get("rejected", 0)
quality_gate_rejected = stats.get("quality_gate_rejected", 0)
no_image = stats.get("no_image", 0)
@ -298,8 +368,11 @@ def notify_pipeline_done(stats: dict[str, Any]) -> None:
"📊 <b>Pipeline abgeschlossen</b>",
f"📥 Neue Artikel importiert: {ingested}",
f"⚙️ Verarbeitet: {processed}",
f"📝 Drafts erstellt: {drafts}",
]
if drafts:
lines.append(f"📝 Drafts erstellt: {drafts}")
if pending_review:
lines.append(f"🕐 Wartet auf Freigabe: {pending_review}")
if rejected:
lines.append(f"🚫 Abgelehnt (Score): {rejected}")
if quality_gate_rejected:

View file

@ -1,6 +1,17 @@
from __future__ import annotations
UI_STATUSES = ("new", "rewrite", "publish", "published", "close", "no_image")
UI_STATUSES = ("new", "rewrite", "freigabe", "publish", "published", "close", "no_image")
# Menschenlesbare Beschriftungen fuer die Admin-UI.
UI_STATUS_LABELS: dict[str, str] = {
"new": "Neu",
"rewrite": "Rewrite",
"freigabe": "Wartet auf Freigabe",
"publish": "Freigegeben / eingeplant",
"published": "Veroeffentlicht",
"close": "Verworfen",
"no_image": "Kein Bild",
}
def internal_to_ui_status(status: str | None) -> str:
@ -9,6 +20,8 @@ def internal_to_ui_status(status: str | None) -> str:
return "publish"
if value == "error":
return "close"
if value == "pending_review":
return "freigabe"
if value == "review":
return "rewrite"
if value in {"new", "rewrite", "published", "no_image"}:
@ -22,16 +35,27 @@ def ui_to_internal_status(status: str | None) -> str:
return "approved"
if value == "close":
return "error"
if value == "freigabe":
return "pending_review"
if value in {"new", "rewrite", "published", "no_image"}:
return value
if value in {"approved", "error", "review"}:
if value in {"approved", "error", "review", "pending_review"}:
return value
return value
def ui_status_label(status: str | None) -> str:
value = (status or "").strip()
return UI_STATUS_LABELS.get(value, value or "new")
# `freigabe` ist das Tor zur Veroeffentlichung: nur ueber diesen Zustand (oder
# einen bewussten manuellen Wechsel) erreicht ein Artikel `publish`, und beides
# stempelt die redaktionelle Freigabe (Art. 50 Abs. 4 KI-VO).
ALLOWED_UI_TRANSITIONS: dict[str, set[str]] = {
"new": {"rewrite", "close"},
"rewrite": {"publish", "close"},
"rewrite": {"freigabe", "publish", "close"},
"freigabe": {"publish", "rewrite", "close"},
"publish": {"published", "close"},
"published": {"rewrite", "close"},
"close": {"rewrite"},

View file

@ -30,7 +30,14 @@
<section class="card">
<h2>{{ article.title }}</h2>
<div class="detail-grid">
<div class="detail-item"><span class="k">Status</span><span><span class="badge">{{ article.status_ui }}</span></span></div>
<div class="detail-item"><span class="k">Status</span><span><span class="badge">{{ status_labels.get(article.status_ui, article.status_ui) }}</span></span></div>
<div class="detail-item"><span class="k">Redaktionelle Freigabe</span><span>
{% if article.editorial_review_at %}
<span class="badge ok">geprüft</span> {{ article.editorial_review_at }} durch {{ article.editorial_review_by }}
{% else %}
<span class="badge bad">offen</span>
{% endif %}
</span></div>
<div class="detail-item"><span class="k">Artikel-Datum</span><span>{{ article.published_at or "-" }}</span></div>
<div class="detail-item"><span class="k">Alter</span><span>{{ article.days_old if article.days_old is not none else "-" }} Tage</span></div>
<div class="detail-item"><span class="k">Relevanz</span><span>{{ article.relevance }}</span></div>
@ -176,9 +183,46 @@
<p class="subtle">Dieser Text wird für den WordPress-Entwurf verwendet, falls vorhanden.</p>
</section>
<section class="card">
<h2>Redaktionelle Freigabe</h2>
{% if article.editorial_review_at %}
<p>
<span class="badge ok">Redaktionell geprüft</span>
am <strong>{{ article.editorial_review_at }}</strong> durch <strong>{{ article.editorial_review_by }}</strong>
</p>
{% if article.editorial_review_note %}<p class="subtle">Notiz: {{ article.editorial_review_note }}</p>{% endif %}
<p class="subtle">
Dieser Eintrag ist der Nachweis der menschlichen redaktionellen Kontrolle
nach Art. 50 Abs. 4 KI-VO. Zeitpunkt und Prüfer setzt das System, sie sind
nicht nachträglich editierbar.
</p>
{% elif article.status_ui in ["freigabe", "rewrite", "no_image"] %}
<p class="subtle">
Erst nach deiner Freigabe geht der Artikel nach WordPress und bekommt einen
Veröffentlichungs-Slot. Datum und Uhrzeit der Prüfung werden automatisch gesetzt.
</p>
<form method="post" action="/admin/articles/{{ article.id }}/approve" class="row">
<input name="note" placeholder="Notiz zur Prüfung (optional)" style="min-width:260px;" />
<button type="submit">✅ Redaktionell geprüft &amp; freigeben</button>
</form>
<div class="row" style="margin-top:8px;">
<form method="post" action="/admin/articles/{{ article.id }}/rewrite-run" class="inline">
<button type="submit" class="secondary" title="Text mit OpenAI neu schreiben lassen">✏️ Neu schreiben</button>
</form>
<form method="post" action="/admin/articles/{{ article.id }}/transition" class="inline">
<input type="hidden" name="target_status" value="close" />
<input type="hidden" name="note" value="Bei der redaktionellen Prüfung verworfen" />
<button type="submit" class="secondary">❌ Verwerfen</button>
</form>
</div>
{% else %}
<p class="subtle">Für diesen Status ist keine Freigabe vorgesehen.</p>
{% endif %}
</section>
<section class="card">
<h2>Status ändern</h2>
{% if article.status_ui in ["new", "rewrite"] %}
{% if article.status_ui in ["new", "rewrite", "no_image"] %}
<form method="post" action="/admin/articles/{{ article.id }}/rewrite-run" class="row" style="margin-bottom:8px;">
<button type="submit">Rewrite ausführen (OpenAI)</button>
</form>
@ -191,7 +235,7 @@
<form method="post" action="/admin/articles/{{ article.id }}/transition" class="row">
<select name="target_status">
{% for s in allowed_transitions %}
<option value="{{ s }}">{{ s }}</option>
<option value="{{ s }}">{{ status_labels.get(s, s) }}</option>
{% endfor %}
</select>
<input name="note" placeholder="Notiz" />

View file

@ -35,6 +35,7 @@
.badge-error { background: #fee2e2; color: #991b1b; }
.badge-published { background: #ede9fe; color: #5b21b6; }
.badge-review { background: #fef3c7; color: #92400e; }
.badge-pending_review { background: #ffedd5; color: #9a3412; }
</style>
</head>
<body>
@ -71,7 +72,7 @@
<label>Status</label>
<select name="status_filter">
<option value="">Alle</option>
{% for s in ["new","review","approved","published","error","no_image"] %}
{% for s in ["new","review","pending_review","approved","published","error","no_image"] %}
<option value="{{ s }}" {% if status_filter == s %}selected{% endif %}>{{ s }}</option>
{% endfor %}
</select>

View file

@ -236,12 +236,19 @@
<section class="card">
<h2>Artikel (Review)</h2>
{% if pending_review_count %}
<p class="flash flash-success" style="padding:8px 12px;">
📝 <strong>{{ pending_review_count }}</strong> Artikel warten auf deine redaktionelle Freigabe —
<a href="/admin/dashboard?status_filter=freigabe">jetzt prüfen</a>.
Erst nach der Freigabe gehen sie nach WordPress.
</p>
{% endif %}
<form method="get" action="/admin/dashboard" class="row filter-row">
<label>Status-Filter</label>
<select name="status_filter">
<option value="" {% if not status_filter %}selected{% endif %}>alle</option>
{% for s in status_options %}
<option value="{{ s }}" {% if status_filter == s %}selected{% endif %}>{{ s }}</option>
<option value="{{ s }}" {% if status_filter == s %}selected{% endif %}>{{ status_labels.get(s, s) }}</option>
{% endfor %}
</select>
<button type="submit" class="secondary">Filtern</button>
@ -267,7 +274,7 @@
<br /><a href="{{ a.canonical_url }}" target="_blank" rel="noopener">Canonical öffnen</a>
{% endif %}
</td>
<td><span class="badge">{{ a.status_ui }}</span></td>
<td><span class="badge">{{ status_labels.get(a.status_ui, a.status_ui) }}</span></td>
<td>
<div class="subtle">Publish: {{ "bereit" if a.publish_ready else "blockiert" }}</div>
{% if not a.publish_ready and a.publish_blockers %}
@ -323,7 +330,7 @@
<form method="post" action="/admin/articles/{{ a.id }}/transition" class="inline">
<select name="target_status">
{% for s in allowed_transitions.get(a.status_ui, []) %}
<option value="{{ s }}">{{ s }}</option>
<option value="{{ s }}">{{ status_labels.get(s, s) }}</option>
{% endfor %}
</select>
{% if allowed_transitions.get(a.status_ui, []) %}

View file

@ -333,7 +333,8 @@ class TestAdminUi(unittest.TestCase):
self.assertEqual(res.status_code, 303)
article = get_article_by_id(article_id)
self.assertIsNotNone(article)
self.assertEqual(article.get("status"), "approved")
# Batch rewrites land in the editorial queue, not in `approved`.
self.assertEqual(article.get("status"), "pending_review")
self.assertIn("generated_tags", article.get("meta_json", ""))
@patch("backend.app.admin_ui.urlopen")

View file

@ -95,15 +95,16 @@ class TestArticleWorkflow(unittest.TestCase):
self.assertEqual(bad.status_code, 410)
@patch("backend.app.main.rewrite_article_text")
def test_rewrite_run_sets_publish_status(self, mock_rewrite) -> None:
def test_rewrite_run_sends_article_to_editorial_queue(self, mock_rewrite) -> None:
"""A rewrite no longer reaches `publish` by itself — a person has to sign off."""
mock_rewrite.return_value = "<h2>Neu</h2><p>Umschreibung</p>"
article_id = self._create_article()
self.client.post(f"/api/articles/{article_id}/transition", json={"target_status": "rewrite"})
r = self.client.post(f"/api/articles/{article_id}/rewrite-run")
self.assertEqual(r.status_code, 200)
self.assertEqual(r.json()["status"], "publish")
self.assertEqual(r.json()["status"], "freigabe")
final = self.client.get(f"/api/articles/{article_id}")
self.assertEqual(final.json()["item"]["status_ui"], "publish")
self.assertEqual(final.json()["item"]["status_ui"], "freigabe")
if __name__ == "__main__":

View file

@ -0,0 +1,333 @@
"""Tests for the editorial sign-off gate (Art. 50 Abs. 4 KI-VO).
The gate exists so that no AI-rewritten article reaches WordPress before a
person read it. These tests pin the two properties that carry the legal weight:
nothing gets published without a stamp, and the stamp records who and when.
"""
import json
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from fastapi.testclient import TestClient
from backend.app import config as config_module
from backend.app import pipeline as pipeline_module
from backend.app.db import init_db
from backend.app.main import app
from backend.app.publisher import _can_publish
from backend.app.repositories import (
ArticleUpsert,
FeedCreate,
SourceCreate,
create_feed,
create_source,
get_article_by_id,
upsert_article,
)
from backend.app.workflow import internal_to_ui_status, ui_to_internal_status
LONG_TEXT = "<p>" + " ".join(["Wort"] * 400) + "</p>"
class EditorialReviewTestBase(unittest.TestCase):
def setUp(self) -> None:
self.tmp_dir = tempfile.TemporaryDirectory()
os.environ["APP_DB_PATH"] = str(Path(self.tmp_dir.name) / "editorial.db")
os.environ["APP_ADMIN_USERNAME"] = "admin"
os.environ["APP_ADMIN_PASSWORD"] = "secret"
config_module.get_settings.cache_clear()
init_db()
self.client = TestClient(app)
def tearDown(self) -> None:
config_module.get_settings.cache_clear()
for key in ("APP_DB_PATH", "APP_ADMIN_USERNAME", "APP_ADMIN_PASSWORD"):
os.environ.pop(key, None)
self.tmp_dir.cleanup()
def _create_article(self, *, with_image: bool = True, status: str = "new", rewritten: str | None = None) -> int:
source_id = create_source(
SourceCreate(
name="Editorial Source",
base_url="https://example.org",
terms_url="https://example.org/terms",
license_name="cc-by",
risk_level="green",
is_enabled=True,
notes=None,
last_reviewed_at="2026-08-01T00:00:00Z",
)
)
feed_id = create_feed(
FeedCreate(
name="Editorial Feed",
url="https://example.org/feed.xml",
source_id=source_id,
is_enabled=True,
)
)
meta: dict = {}
if with_image:
meta["image_review"] = {"selected_url": "https://example.org/bild.jpg"}
return upsert_article(
ArticleUpsert(
feed_id=feed_id,
source_article_id="ed-1",
source_hash="hash-ed-1",
title="Artikel zur Freigabe",
source_url="https://example.org/artikel-1",
canonical_url=None,
published_at="2026-08-20T08:00:00Z",
author="Redaktion",
summary="Zusammenfassung",
content_raw=LONG_TEXT,
content_rewritten=rewritten,
image_urls_json=json.dumps(["https://example.org/bild.jpg"]),
press_contact=None,
source_name_snapshot="Editorial Source",
source_terms_url_snapshot="https://example.org/terms",
source_license_name_snapshot="cc-by",
legal_checked=True,
legal_checked_at="2026-08-20T08:00:00Z",
legal_note=None,
wp_post_id=None,
wp_post_url=None,
publish_attempts=0,
publish_last_error=None,
published_to_wp_at=None,
word_count=0,
status=status,
meta_json=json.dumps(meta, ensure_ascii=False),
)
)
class TestStatusModel(unittest.TestCase):
def test_pending_review_maps_to_freigabe(self) -> None:
self.assertEqual(internal_to_ui_status("pending_review"), "freigabe")
self.assertEqual(ui_to_internal_status("freigabe"), "pending_review")
def test_existing_status_mapping_unchanged(self) -> None:
self.assertEqual(internal_to_ui_status("approved"), "publish")
self.assertEqual(ui_to_internal_status("publish"), "approved")
class TestRewriteStopsAtGate(EditorialReviewTestBase):
def test_rewrite_without_draft_parks_article_in_review_queue(self) -> None:
article_id = self._create_article()
article = get_article_by_id(article_id)
with patch.object(pipeline_module, "rewrite_article_text", return_value=LONG_TEXT), \
patch.object(pipeline_module, "generate_article_tags", return_value=["Camping"]), \
patch.object(pipeline_module, "publish_article_draft") as wp_draft, \
patch.object(pipeline_module, "reserve_publish_slot") as reserve:
wp_post_id, wp_post_url = pipeline_module._do_rewrite_and_draft(article, create_wp_draft=False)
self.assertIsNone(wp_post_id)
self.assertIsNone(wp_post_url)
wp_draft.assert_not_called()
reserve.assert_not_called()
stored = get_article_by_id(article_id)
self.assertEqual(stored["status"], "pending_review")
self.assertIsNone(stored["editorial_review_at"])
self.assertIsNone(stored["scheduled_publish_at"])
self.assertTrue(stored["content_rewritten"])
class TestPipelineGate(EditorialReviewTestBase):
def test_high_scoring_article_waits_for_approval(self) -> None:
"""The full pipeline path: high score, but still no WordPress post."""
from backend.app import telegram_bot
article_id = self._create_article()
article = get_article_by_id(article_id)
stats = pipeline_module.PipelineStats()
settings = config_module.get_settings()
with patch.object(pipeline_module, "score_article_relevance", return_value={"score": 95, "reason": "passt", "topics": []}), \
patch.object(pipeline_module, "rewrite_article_text", return_value=LONG_TEXT), \
patch.object(pipeline_module, "generate_article_tags", return_value=["Camping"]), \
patch.object(pipeline_module, "publish_article_draft") as wp_draft, \
patch.object(pipeline_module, "reserve_publish_slot") as reserve, \
patch.object(telegram_bot, "notify_pending_review") as notify_pending, \
patch.object(telegram_bot, "notify_new_draft") as notify_draft:
pipeline_module._process_article(article, stats, settings)
wp_draft.assert_not_called()
reserve.assert_not_called()
notify_draft.assert_not_called()
notify_pending.assert_called_once()
self.assertEqual(stats.pending_review, 1)
self.assertEqual(stats.drafts_created, 0)
stored = get_article_by_id(article_id)
self.assertEqual(stored["status"], "pending_review")
self.assertIsNone(stored["wp_post_id"])
class TestApproveArticle(EditorialReviewTestBase):
def test_approval_stamps_reviewer_and_publishes(self) -> None:
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") as reserve, \
patch.object(pipeline_module, "publish_article_draft", return_value=(4711, "https://blog/p/4711")) as wp_draft:
result = pipeline_module.approve_article(article_id, actor="oliver", note="gelesen und geprüft")
reserve.assert_called_once_with(article_id)
wp_draft.assert_called_once()
self.assertEqual(result["wp_post_id"], 4711)
self.assertEqual(result["scheduled_publish_at"], "2026-08-25 09:00:00")
stored = get_article_by_id(article_id)
self.assertEqual(stored["status"], "approved")
self.assertEqual(stored["editorial_review_by"], "oliver")
self.assertEqual(stored["editorial_review_note"], "gelesen und geprüft")
self.assertTrue(stored["editorial_review_at"])
self.assertEqual(stored["wp_post_id"], 4711)
# The sign-off is also in the audit trail.
events = json.loads(stored["meta_json"]).get("review_events", [])
self.assertTrue(any(e.get("event") == "editorial_review" for e in events))
def test_approval_refuses_article_without_image(self) -> None:
article_id = self._create_article(status="pending_review", rewritten=LONG_TEXT, with_image=False)
with patch.object(pipeline_module, "publish_article_draft") as wp_draft:
with self.assertRaises(ValueError):
pipeline_module.approve_article(article_id, actor="oliver")
wp_draft.assert_not_called()
stored = get_article_by_id(article_id)
self.assertIsNone(stored["editorial_review_at"])
self.assertEqual(stored["status"], "pending_review")
def test_approval_refuses_article_without_rewrite(self) -> None:
article_id = self._create_article(status="pending_review", rewritten=None)
with self.assertRaises(ValueError):
pipeline_module.approve_article(article_id, actor="oliver")
self.assertIsNone(get_article_by_id(article_id)["editorial_review_at"])
def test_wordpress_failure_returns_article_to_the_queue(self) -> None:
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", side_effect=RuntimeError("WP 401")):
with self.assertRaises(RuntimeError):
pipeline_module.approve_article(article_id, actor="oliver")
stored = get_article_by_id(article_id)
self.assertEqual(stored["status"], "pending_review")
self.assertIsNone(stored["scheduled_publish_at"])
# The review itself happened, so the stamp stays: retrying is enough.
self.assertTrue(stored["editorial_review_at"])
class TestAdminUiApproval(EditorialReviewTestBase):
def _login(self) -> None:
self.client.post(
"/admin/login",
data={"username": "admin", "password": "secret"},
follow_redirects=False,
)
def test_detail_page_offers_approval_button(self) -> None:
self._login()
article_id = self._create_article(status="pending_review", rewritten=LONG_TEXT)
page = self.client.get(f"/admin/articles/{article_id}")
self.assertEqual(page.status_code, 200)
self.assertIn("Redaktionelle Freigabe", page.text)
self.assertIn(f"/admin/articles/{article_id}/approve", page.text)
def test_approve_route_stamps_and_schedules(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=(99, "https://blog/p/99")):
response = self.client.post(
f"/admin/articles/{article_id}/approve",
data={"note": "geprüft"},
follow_redirects=False,
)
self.assertEqual(response.status_code, 303)
stored = get_article_by_id(article_id)
self.assertEqual(stored["status"], "approved")
self.assertEqual(stored["editorial_review_by"], "admin")
self.assertTrue(stored["editorial_review_at"])
def test_manual_transition_to_publish_also_stamps(self) -> None:
"""The status dropdown must not be a way around the gate."""
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=(100, "https://blog/p/100")):
self.client.post(
f"/admin/articles/{article_id}/transition",
data={"target_status": "publish", "note": ""},
follow_redirects=False,
)
stored = get_article_by_id(article_id)
self.assertEqual(stored["status"], "approved")
self.assertTrue(stored["editorial_review_at"])
class TestStampFollowsTheText(EditorialReviewTestBase):
def test_rewrite_after_approval_drops_the_stale_stamp(self) -> None:
"""A stamp says a person read *this* text. New text, new sign-off."""
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=(1, "https://blog/p/1")):
pipeline_module.approve_article(article_id, actor="oliver")
self.assertTrue(get_article_by_id(article_id)["editorial_review_at"])
article = get_article_by_id(article_id)
with patch.object(pipeline_module, "rewrite_article_text", return_value=LONG_TEXT + "<p>neu</p>"), \
patch.object(pipeline_module, "generate_article_tags", return_value=[]):
pipeline_module._do_rewrite_and_draft(article, create_wp_draft=False)
stored = get_article_by_id(article_id)
self.assertEqual(stored["status"], "pending_review")
self.assertIsNone(stored["editorial_review_at"])
self.assertIsNone(stored["editorial_review_by"])
class TestDashboardSurfacesQueue(EditorialReviewTestBase):
def test_dashboard_shows_pending_review_banner(self) -> None:
self.client.post(
"/admin/login",
data={"username": "admin", "password": "secret"},
follow_redirects=False,
)
self._create_article(status="pending_review", rewritten=LONG_TEXT)
page = self.client.get("/admin/dashboard")
self.assertEqual(page.status_code, 200)
self.assertIn("warten auf deine redaktionelle Freigabe", page.text)
self.assertIn("status_filter=freigabe", page.text)
class TestLegacyArticlesKeepPublishing(EditorialReviewTestBase):
def test_approved_article_without_stamp_still_publishes(self) -> None:
"""Altbestand: approved before the gate existed, must not get stuck."""
article_id = self._create_article(status="approved", rewritten=LONG_TEXT)
article = get_article_by_id(article_id)
self.assertIsNone(article["editorial_review_at"])
allowed, reason = _can_publish(article)
self.assertTrue(allowed, msg=reason)
if __name__ == "__main__":
unittest.main()

View file

@ -2,19 +2,32 @@
## Überblick
Das System läuft vollautomatisch und benötigt nur noch gelegentliche Telegram-Interaktion.
Import, Bewertung und Rewrite laufen automatisch. **Veröffentlicht wird erst nach
deiner redaktionellen Freigabe im Portal** — siehe `docs/KI-VO.md` für den
rechtlichen Hintergrund (Art. 50 Abs. 4 KI-VO).
```
N8N (2× täglich, 08:00 + 16:00 Uhr)
└─► POST /api/n8n/pipeline (X-API-Key Header)
├── RSS Ingestion (alle aktivierten Feeds)
├── Relevanz-Score per GPT (0100)
│ ├── Score ≥ 80 → Rewrite + WP-Draft + Telegram
│ ├── Score ≥ 80 → Rewrite + Tags → Status "Wartet auf Freigabe"
│ ├── Score 6079 → Telegram-Warnung + manueller Override möglich
│ └── Score < 60 Abgelehnt + tägliche Telegram-Liste
└── Pipeline-Zusammenfassung via Telegram
Danach, manuell im Portal (news.vanityontour.de):
Artikel öffnen → lesen → [✅ Redaktionell geprüft & freigeben]
├── Stempel: Prüfer + Systemzeit (nicht editierbar)
├── Publish-Slot reservieren
└── WordPress-Beitrag anlegen (Status "future" zum Slot)
```
Vor der Freigabe existiert **kein** WordPress-Beitrag und **kein** belegter
Publish-Slot. Wer das Gate abschalten will: `EDITORIAL_REVIEW_REQUIRED=false`
dann gilt wieder der alte, vollautomatische Ablauf, und der KI-Hinweistext auf
dem Blog muss angepasst werden.
---
## Einrichtung
@ -82,18 +95,32 @@ In N8N einen neuen Workflow erstellen:
## Telegram-Benachrichtigungen
### Neuer Draft erstellt
Wenn ein Artikel erfolgreich verarbeitet wurde:
### Artikel wartet auf Freigabe
Wenn ein Artikel umgeschrieben wurde und geprüft werden muss:
```
✅ Neuer Draft erstellt
📝 Neuer Artikel wartet auf Freigabe
📰 [Artikel-Titel]
🟢 Relevanz-Score: 87/100
📅 Vorgeschlagene Veröffentlichung: Mo, 24.03.2026 um 09:00 Uhr
📄 312 Wörter
🏷 #VanLife #Camping #Wohnmobil
🔗 Draft in WordPress öffnen
🔗 Im Portal prüfen und freigeben
Erst nach der Freigabe geht der Beitrag nach WordPress und wird eingeplant.
```
[✏️ Neu schreiben] [❌ Verwerfen]
Diese Nachricht trägt **bewusst keinen Freigabe-Button**: Der Sinn des Gates ist,
dass der Artikel gelesen wurde. Freigeben, neu schreiben und verwerfen passiert
auf der Artikelseite im Portal, einen Tipp auf den Link entfernt.
### Freigegeben und eingeplant
Bestätigung nach der Freigabe im Portal:
```
✅ Freigegeben und eingeplant
📰 [Artikel-Titel]
👤 Geprüft von: admin
📅 Veröffentlichung: Mo, 24.08.2026 um 09:00 Uhr
🔗 Beitrag in WordPress
```
### Relevanz-Warnung (Score 6079)
@ -118,8 +145,8 @@ Der GPT-basierte Score bewertet die Themenrelevanz für den VanLife/Camping-Blog
| Score | Aktion |
|-------|--------|
| 80100 | Automatisch verarbeiten |
| 6079 | Telegram-Warnung, manueller Override |
| 80100 | Rewrite, danach Warteschlange „Wartet auf Freigabe" |
| 6079 | Telegram-Warnung, manueller Override (führt ebenfalls in die Warteschlange) |
| 059 | Automatisch abgelehnt |
Themen die hoch scored werden: Campingplätze, Stellplätze, Wohnmobile, Van-Ausbau,
@ -140,8 +167,10 @@ PIPELINE_RELEVANCE_WARN=60
- Zwischen den Slots liegen jeweils **3 Stunden Abstand**
- Veröffentlichungsfenster: **09:00 bis 19:00 Uhr** (`Europe/Berlin`)
- Gleichmäßig über die Woche verteilt
- Der Vorschlag erscheint in der Telegram-Nachricht
- Manuell in WordPress setzen oder über WP Scheduling-Plugin automatisieren
- Der Slot wird **im Moment der Freigabe** reserviert, nicht schon beim Rewrite —
ein wartender Artikel blockiert also keinen Sendeplatz
- Der zugeteilte Slot erscheint in der Freigabe-Bestätigung per Telegram
- WordPress veröffentlicht den Beitrag zum Slot selbst (Status `future`)
Einstellbar via:
```

119
docs/KI-VO.md Normal file
View file

@ -0,0 +1,119 @@
# KI-Verordnung: redaktionelle Freigabe vor der Veröffentlichung
Stand: 24.08.2026. Keine Rechtsberatung — die Umsetzung folgt der Auslegung, die
unten begründet ist. Bei wirtschaftlich kritischen Fällen juristisch prüfen.
## Warum das Gate existiert
Die Pipeline lässt ein Sprachmodell (OpenAI) Nachrichtentexte **neu schreiben**,
verschlagwortet sie und bewertet ihre Relevanz. Das Ergebnis wird als Beitrag auf
`vanityontour.de` veröffentlicht — also ein KI-erzeugter Text, der die
Öffentlichkeit informiert.
Art. 50 Abs. 4 UAbs. 2 KI-VO (Verordnung (EU) 2024/1689, Transparenzpflichten
anwendbar seit dem 02.08.2026) verlangt für solche Texte die Offenlegung, dass
sie künstlich erzeugt oder manipuliert wurden. **Ausgenommen** sind Inhalte, die
- einer **menschlichen Überprüfung bzw. redaktionellen Kontrolle** unterzogen
wurden **und**
- für die eine natürliche oder juristische Person die **redaktionelle
Verantwortung** trägt.
Der KI-Transparenzhinweis auf dem Blog (Plugin `vot-ki-hinweis`) sagt genau das
zu: „Jeder Text wird von mir redaktionell geprüft, auf Fakten kontrolliert und
inhaltlich verantwortet." Bis zum 24.08.2026 lief die News-Pipeline jedoch
vollautomatisch: Rewrite, WordPress-Beitrag und geplante Veröffentlichung ohne
menschlichen Zwischenschritt. Die zugesagte Kontrolle fand faktisch nicht statt.
Das Freigabe-Gate schließt diese Lücke: Es macht die Aussage wahr und
dokumentiert sie nachprüfbar.
## Der Ablauf
```
N8N (2× täglich) → POST /api/n8n/pipeline
├── RSS-Ingestion
├── Relevanz-Score (GPT)
│ ├── < 60 abgelehnt
│ ├── 6079 → Telegram-Warnung, Übernahme per Button möglich
│ └── ≥ 80 → Rewrite + Tags (GPT)
└── Status: pending_review ──► KEIN WordPress-Beitrag, KEIN Slot
Telegram: Info + Link ins Portal
Mensch öffnet den Artikel im Portal, liest ihn
┌─────────────────────────┼──────────────────────────┐
▼ ▼ ▼
„Redaktionell geprüft „Rewrite ausführen" „Verwerfen"
& freigeben" (neu schreiben) (Status close)
Stempel: editorial_review_at / _by / _note (Systemzeit, nicht editierbar)
Status: approved → Publish-Slot reservieren → WordPress-Beitrag (future)
→ WordPress veröffentlicht zum Slot
```
Wichtig an der Reihenfolge:
- **Kein WordPress-Beitrag vor der Freigabe.** Sonst läge dort ein
`future`-Beitrag, der sich selbst veröffentlicht, bevor jemand hingesehen hat.
- **Kein Slot vor der Freigabe.** Es gibt vier feste Slots pro Tag; ein Artikel,
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.
## Wo der Nachweis liegt
| Ort | Inhalt |
|-----|--------|
| `articles.editorial_review_at` | Zeitpunkt der Freigabe (UTC, ISO 8601), vom System gesetzt |
| `articles.editorial_review_by` | angemeldeter Benutzer, der freigegeben hat |
| `articles.editorial_review_note` | optionale Notiz zur Prüfung |
| `meta_json.review_events[]` | Ereignis `editorial_review` im Audit-Trail, zusätzlich der Statuswechsel |
Der Zeitstempel ist **nie** ein Eingabefeld. Er entsteht ausschließlich beim
Klick auf „Redaktionell geprüft & freigeben" (bzw. beim manuellen Statuswechsel
nach `publish`) und kann in der Oberfläche nicht nachträglich gesetzt werden.
## Kein Schlupfloch nach `publish`
Jeder Weg in den Status `approved` stempelt die Freigabe:
- `POST /admin/articles/{id}/approve` — der reguläre Button
- `POST /admin/articles/{id}/transition` mit Ziel `publish` — leitet auf denselben
Weg um, wenn noch kein Stempel vorliegt
- `POST /api/articles/{id}/transition` mit Ziel `publish` — stempelt ebenfalls
Die Pipeline selbst erreicht `approved` nicht mehr, solange
`EDITORIAL_REVIEW_REQUIRED=true` gesetzt ist.
## Altbestand
Artikel, die vor dem 24.08.2026 freigegeben oder veröffentlicht wurden, haben
keinen Stempel und brauchen keinen: Sie durchlaufen die neue Pipeline nicht
erneut, und der Publisher blockiert sie nicht. Das Gate greift ausschließlich für
Artikel, die ab sofort neu verarbeitet werden.
## Konfiguration
| Variable | Default | Wirkung |
|----------|---------|---------|
| `EDITORIAL_REVIEW_REQUIRED` | `true` | Gate aktiv; `false` stellt den alten vollautomatischen Ablauf wieder her |
| `PORTAL_BASE_URL` | `https://news.vanityontour.de` | Basis für die Deep-Links in Telegram |
Wird das Gate abgeschaltet, ist der KI-Hinweistext auf dem Blog anzupassen — die
Zusage der redaktionellen Prüfung träfe dann nicht mehr zu.
## Was bewusst nicht umgesetzt ist
- **Maschinenlesbare Markierung der KI-Ausgabe** (Art. 50 Abs. 2) ist eine
Pflicht des Anbieters des KI-Systems, nicht des Betreibers. Sie liegt bei
OpenAI.
- **Ein zweiter, abweichender Hinweistext für vollautomatische Beiträge** wird
nicht gebraucht, solange das Gate aktiv ist: Es gibt dann keine
vollautomatischen Beiträge mehr.
## Referenzen
- Verordnung (EU) 2024/1689 (KI-VO), Art. 50: https://eur-lex.europa.eu/legal-content/DE/TXT/?uri=OJ:L_202401689
- Plugin `vot-ki-hinweis` (Hinweistext auf dem Blog): eigenes Repository `VoT-KI-Hinweis`

View file

@ -19,6 +19,9 @@
- [x] Artikel-Datum + Relevanzscore im UI/Export
## Recht/Qualitaet
- [x] Redaktionelle Freigabe vor Veroeffentlichung (Art. 50 Abs. 4 KI-VO), siehe `docs/KI-VO.md`
- [ ] KI-Register/Export der Freigaben fuer die Dokumentation (CSV je Zeitraum)
- [ ] Erinnerung, wenn Artikel laenger als X Tage auf Freigabe warten
- [x] Source-Policy in DB + Admin-UI abbilden
- [x] Pflichtfelder je Quelle erzwingen (Autor, URL, Lizenz, Hinweise)
- [x] Auto-Block bei fehlender Lizenzinfo

View file

@ -1,5 +1,11 @@
# Recht und Quellen
## KI-Verordnung
- Jeder KI-umgeschriebene Artikel braucht vor der Veroeffentlichung die
redaktionelle Freigabe im Portal (Art. 50 Abs. 4 KI-VO)
- Nachweis: `editorial_review_at` / `_by` / `_note` je Artikel
- Details und Begruendung: `docs/KI-VO.md`
## Grundregeln
- Nur freigegebene Quellen aus Source-Register
- Pflicht-Attribution pro Artikel