diff --git a/CHANGELOG.md b/CHANGELOG.md index 66b7237..1f7b422 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index b3c2b4a..a9c493c 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/backend/.env.example b/backend/.env.example index c6d3350..d97282b 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 diff --git a/backend/app/admin_ui.py b/backend/app/admin_ui.py index a25199c..8700a44 100644 --- a/backend/app/admin_ui.py +++ b/backend/app/admin_ui.py @@ -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") diff --git a/backend/app/config.py b/backend/app/config.py index a0c8f66..de19502 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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: diff --git a/backend/app/db.py b/backend/app/db.py index 6aa1e0f..5a1ac4c 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py index b4776af..fafaab9 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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") diff --git a/backend/app/pipeline.py b/backend/app/pipeline.py index 93a251b..9bad813 100644 --- a/backend/app/pipeline.py +++ b/backend/app/pipeline.py @@ -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"📊 Pipeline-Status\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}" diff --git a/backend/app/repositories.py b/backend/app/repositories.py index 8032077..30030e9 100644 --- a/backend/app/repositories.py +++ b/backend/app/repositories.py @@ -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 diff --git a/backend/app/telegram_bot.py b/backend/app/telegram_bot.py index 880a49d..d0972c6 100644 --- a/backend/app/telegram_bot.py +++ b/backend/app/telegram_bot.py @@ -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 = [ + "📝 Neuer Artikel wartet auf Freigabe", + f"📰 {title}", + f"{_score_emoji(score)} Relevanz-Score: {score}/100", + f"📄 {words} Wörter", + ] + if tags_str: + text_parts.append(f"🏷 {tags_str}") + text_parts.append(f'🔗 Im Portal prüfen und freigeben') + text_parts.append( + "Erst nach der Freigabe geht der Beitrag nach WordPress und wird eingeplant." + ) + 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 = [ + "✅ Freigegeben und eingeplant", + f"📰 {title}", + f"👤 Geprüft von: {reviewer}", + ] + if scheduled_at: + parts.append(f"📅 Veröffentlichung: {scheduled_at}") + if wp_url: + parts.append(f'🔗 Beitrag in WordPress') + 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"📰 {title}\n" f"{_score_emoji(score)} Score: {score}/100\n" f"💬 {reason}\n" - f'🔗 Originalartikel' + f'🔗 Originalartikel\n' + f'📋 Im Portal ansehen' ) 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: "📊 Pipeline abgeschlossen", 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: diff --git a/backend/app/workflow.py b/backend/app/workflow.py index 83e9b63..bdc0d8e 100644 --- a/backend/app/workflow.py +++ b/backend/app/workflow.py @@ -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"}, diff --git a/backend/templates/admin_article_detail.html b/backend/templates/admin_article_detail.html index 1c16658..fd573ef 100644 --- a/backend/templates/admin_article_detail.html +++ b/backend/templates/admin_article_detail.html @@ -30,7 +30,14 @@

{{ article.title }}

-
Status{{ article.status_ui }}
+
Status{{ status_labels.get(article.status_ui, article.status_ui) }}
+
Redaktionelle Freigabe + {% if article.editorial_review_at %} + geprüft {{ article.editorial_review_at }} durch {{ article.editorial_review_by }} + {% else %} + offen + {% endif %} +
Artikel-Datum{{ article.published_at or "-" }}
Alter{{ article.days_old if article.days_old is not none else "-" }} Tage
Relevanz{{ article.relevance }}
@@ -176,9 +183,46 @@

Dieser Text wird für den WordPress-Entwurf verwendet, falls vorhanden.

+
+

Redaktionelle Freigabe

+ {% if article.editorial_review_at %} +

+ Redaktionell geprüft + am {{ article.editorial_review_at }} durch {{ article.editorial_review_by }} +

+ {% if article.editorial_review_note %}

Notiz: {{ article.editorial_review_note }}

{% endif %} +

+ 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. +

+ {% elif article.status_ui in ["freigabe", "rewrite", "no_image"] %} +

+ Erst nach deiner Freigabe geht der Artikel nach WordPress und bekommt einen + Veröffentlichungs-Slot. Datum und Uhrzeit der Prüfung werden automatisch gesetzt. +

+
+ + +
+
+
+ +
+
+ + + +
+
+ {% else %} +

Für diesen Status ist keine Freigabe vorgesehen.

+ {% endif %} +
+

Status ändern

- {% if article.status_ui in ["new", "rewrite"] %} + {% if article.status_ui in ["new", "rewrite", "no_image"] %}
@@ -191,7 +235,7 @@
diff --git a/backend/templates/admin_article_list.html b/backend/templates/admin_article_list.html index 38bfb22..0b22b00 100644 --- a/backend/templates/admin_article_list.html +++ b/backend/templates/admin_article_list.html @@ -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; } @@ -71,7 +72,7 @@ diff --git a/backend/templates/admin_dashboard.html b/backend/templates/admin_dashboard.html index 0795b96..1202683 100644 --- a/backend/templates/admin_dashboard.html +++ b/backend/templates/admin_dashboard.html @@ -236,12 +236,19 @@

Artikel (Review)

+ {% if pending_review_count %} +

+ 📝 {{ pending_review_count }} Artikel warten auf deine redaktionelle Freigabe — + jetzt prüfen. + Erst nach der Freigabe gehen sie nach WordPress. +

+ {% endif %} @@ -267,7 +274,7 @@
Canonical öffnen {% endif %} - {{ a.status_ui }} + {{ status_labels.get(a.status_ui, a.status_ui) }}
Publish: {{ "bereit" if a.publish_ready else "blockiert" }}
{% if not a.publish_ready and a.publish_blockers %} @@ -323,7 +330,7 @@ {% if allowed_transitions.get(a.status_ui, []) %} diff --git a/backend/tests/test_admin_ui.py b/backend/tests/test_admin_ui.py index c7b6ebf..1cb6827 100644 --- a/backend/tests/test_admin_ui.py +++ b/backend/tests/test_admin_ui.py @@ -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") diff --git a/backend/tests/test_article_workflow.py b/backend/tests/test_article_workflow.py index 094b595..662674b 100644 --- a/backend/tests/test_article_workflow.py +++ b/backend/tests/test_article_workflow.py @@ -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 = "

Neu

Umschreibung

" 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__": diff --git a/backend/tests/test_editorial_review.py b/backend/tests/test_editorial_review.py new file mode 100644 index 0000000..70592ab --- /dev/null +++ b/backend/tests/test_editorial_review.py @@ -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 = "

" + " ".join(["Wort"] * 400) + "

" + + +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 + "

neu

"), \ + 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() diff --git a/docs/AUTOMATION.md b/docs/AUTOMATION.md index 58ab211..ee712ad 100644 --- a/docs/AUTOMATION.md +++ b/docs/AUTOMATION.md @@ -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 (0–100) - │ ├── Score ≥ 80 → Rewrite + WP-Draft + Telegram + │ ├── Score ≥ 80 → Rewrite + Tags → Status "Wartet auf Freigabe" │ ├── Score 60–79 → 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 60–79) @@ -118,8 +145,8 @@ Der GPT-basierte Score bewertet die Themenrelevanz für den VanLife/Camping-Blog | Score | Aktion | |-------|--------| -| 80–100 | Automatisch verarbeiten | -| 60–79 | Telegram-Warnung, manueller Override | +| 80–100 | Rewrite, danach Warteschlange „Wartet auf Freigabe" | +| 60–79 | Telegram-Warnung, manueller Override (führt ebenfalls in die Warteschlange) | | 0–59 | 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: ``` diff --git a/docs/KI-VO.md b/docs/KI-VO.md new file mode 100644 index 0000000..3ff573d --- /dev/null +++ b/docs/KI-VO.md @@ -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 + │ ├── 60–79 → 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` diff --git a/docs/TODO.md b/docs/TODO.md index fee4a67..c2e5812 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -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 diff --git a/docs/wiki/Recht-Quellen.md b/docs/wiki/Recht-Quellen.md index 212f0d5..567c046 100644 --- a/docs/wiki/Recht-Quellen.md +++ b/docs/wiki/Recht-Quellen.md @@ -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