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:
parent
682755c6a0
commit
a233012887
21 changed files with 1151 additions and 64 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue