diff --git a/backend/app/admin_ui.py b/backend/app/admin_ui.py index bde281d..e9bfae4 100644 --- a/backend/app/admin_ui.py +++ b/backend/app/admin_ui.py @@ -17,6 +17,7 @@ from .ingestion import run_ingestion from .policy import evaluate_source_policy from .publisher import enqueue_publish, run_publisher from .relevance import article_age_days, article_relevance +from .rewrite import rewrite_article_text from .repositories import ( FeedCreate, SourceCreate, @@ -31,19 +32,21 @@ from .repositories import ( list_sources, set_article_image_decision, set_article_legal_review, + upsert_article, update_article_status, + ArticleUpsert, ) +from .workflow import ALLOWED_UI_TRANSITIONS, 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": ("review", "rewrite", "error"), - "rewrite": ("review", "error"), - "review": ("approved", "rewrite", "error"), - "approved": ("published", "error"), - "published": ("error",), - "error": ("review", "rewrite"), + "new": ("rewrite", "close"), + "rewrite": ("publish", "close"), + "publish": ("published", "close"), + "published": ("close",), + "close": ("rewrite",), } IMAGE_PROXY_USER_AGENT = "rss-news-admin/1.0" @@ -158,10 +161,8 @@ 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 article.get("status") not in {"approved", "published"}: - reasons.append("Status ist nicht 'approved'") - if int(article.get("legal_checked", 0)) != 1: - reasons.append("Rechtsfreigabe fehlt") + if internal_to_ui_status(article.get("status")) not in {"publish", "published"}: + reasons.append("Status ist nicht 'publish'") 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: @@ -311,8 +312,9 @@ def admin_dashboard(request: Request): job["error_category"] = category job["error_hint"] = hint status_filter = request.query_params.get("status_filter") - if status_filter in {"new", "rewrite", "review", "approved", "published", "error"}: - articles = list_articles(limit=100, status_filter=status_filter) + internal_filter = ui_to_internal_status(status_filter) if status_filter else None + if status_filter in set(UI_STATUSES): + articles = list_articles(limit=100, status_filter=internal_filter) else: status_filter = "" articles = list_articles(limit=100) @@ -336,6 +338,7 @@ def admin_dashboard(request: Request): article["extraction_error"] = extraction.get("extraction_error") if isinstance(extraction.get("extraction_error"), str) else None article["days_old"] = article_age_days(article.get("published_at")) article["relevance"] = article_relevance(article.get("published_at")) + article["status_ui"] = internal_to_ui_status(article.get("status")) return templates.TemplateResponse( request, @@ -350,7 +353,7 @@ def admin_dashboard(request: Request): "runs": runs, "publish_jobs": publish_jobs, "articles": articles, - "status_options": ["new", "rewrite", "review", "approved", "published", "error"], + "status_options": list(UI_STATUSES), "allowed_transitions": ALLOWED_TRANSITIONS, "status_filter": status_filter, "flash_msg": request.query_params.get("msg", ""), @@ -388,6 +391,7 @@ def admin_article_detail(request: Request, article_id: int): ) article["days_old"] = article_age_days(article.get("published_at")) article["relevance"] = article_relevance(article.get("published_at")) + article["status_ui"] = internal_to_ui_status(article.get("status")) feed = get_feed_by_id(int(article["feed_id"])) if article.get("feed_id") else None checklist = _legal_checklist(article, feed) @@ -401,7 +405,7 @@ def admin_article_detail(request: Request, article_id: int): "article": article, "feed": feed, "checklist": checklist, - "allowed_transitions": ALLOWED_TRANSITIONS.get(article.get("status"), ()), + "allowed_transitions": ALLOWED_TRANSITIONS.get(article.get("status_ui"), ()), "flash_msg": request.query_params.get("msg", ""), "flash_type": request.query_params.get("type", "success"), }, @@ -565,12 +569,56 @@ def admin_review_article(request: Request, article_id: int, decision: str = Form if not user: return RedirectResponse(url="/admin/login", status_code=303) + return _dashboard_redirect(msg="Review-Aktion wurde durch Rewrite ersetzt", msg_type="error") + + +@router.post("/admin/articles/{article_id}/rewrite-run") +def admin_rewrite_run(request: Request, article_id: int): + user = _admin_user(request) + if not user: + return RedirectResponse(url="/admin/login", status_code=303) article = get_article_by_id(article_id) - if article and article.get("status") == "review" and decision in {"approve", "reject"}: - target = "approved" if decision == "approve" else "rewrite" - update_article_status(article_id, target, actor=user, note=note or None, decision=decision) - return _dashboard_redirect(msg=f"Artikel #{article_id}: {decision}") - return _dashboard_redirect(msg=f"Review-Aktion ungueltig fuer Artikel #{article_id}", msg_type="error") + 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") + try: + rewritten = rewrite_article_text(article) + except Exception as exc: + return _dashboard_redirect(msg=f"Rewrite fehlgeschlagen fuer Artikel #{article_id}: {exc}", msg_type="error") + + upsert_article( + ArticleUpsert( + feed_id=article.get("feed_id"), + source_article_id=article.get("source_article_id"), + source_hash=article.get("source_hash"), + title=article.get("title"), + source_url=article.get("source_url"), + canonical_url=article.get("canonical_url"), + published_at=article.get("published_at"), + author=article.get("author"), + summary=article.get("summary"), + content_raw=article.get("content_raw"), + content_rewritten=rewritten, + image_urls_json=article.get("image_urls_json"), + press_contact=article.get("press_contact"), + source_name_snapshot=article.get("source_name_snapshot"), + source_terms_url_snapshot=article.get("source_terms_url_snapshot"), + source_license_name_snapshot=article.get("source_license_name_snapshot"), + legal_checked=bool(int(article.get("legal_checked", 0))), + legal_checked_at=article.get("legal_checked_at"), + legal_note=article.get("legal_note"), + wp_post_id=article.get("wp_post_id"), + wp_post_url=article.get("wp_post_url"), + publish_attempts=int(article.get("publish_attempts", 0)), + 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", + meta_json=article.get("meta_json"), + ) + ) + return _dashboard_redirect(msg=f"Rewrite fertig fuer Artikel #{article_id} -> publish") @router.post("/admin/articles/{article_id}/transition") @@ -581,10 +629,10 @@ def admin_transition_article(request: Request, article_id: int, target_status: s article = get_article_by_id(article_id) if article: - current = article.get("status") - if target_status in ALLOWED_TRANSITIONS.get(current, ()): - if target_status == "published" and int(article.get("legal_checked", 0)) != 1: - return _dashboard_redirect(msg=f"Publish blockiert fuer Artikel #{article_id}: Rechtsfreigabe fehlt", msg_type="error") - update_article_status(article_id, target_status, actor=user, note=note or None) - return _dashboard_redirect(msg=f"Artikel #{article_id}: {current} -> {target_status}") + current_ui = internal_to_ui_status(article.get("status")) + 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, ()): + 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 fc52ec3..43629ba 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -30,6 +30,8 @@ class Settings(BaseSettings): wordpress_username: str | None = Field(default=None, validation_alias=AliasChoices("WORDPRESS_USERNAME", "WP_USERNAME")) wordpress_app_password: str | None = Field(default=None, validation_alias=AliasChoices("WORDPRESS_APP_PASSWORD", "WP_PASSWORD")) wordpress_default_status: str = "draft" + openai_api_key: str | None = Field(default=None, validation_alias=AliasChoices("OPENAI_API_KEY")) + openai_model: str = "gpt-4o-mini" @lru_cache(maxsize=1) diff --git a/backend/app/main.py b/backend/app/main.py index c0a0143..4dcee28 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -18,6 +18,7 @@ from .ingestion import run_ingestion from .policy import evaluate_source_policy, is_source_allowed from .publisher import enqueue_publish, run_publisher from .relevance import article_age_days, article_relevance +from .rewrite import rewrite_article_text from .repositories import ( ArticleUpsert, FeedCreate, @@ -40,6 +41,7 @@ from .repositories import ( update_article_status, upsert_article as repo_upsert_article, ) +from .workflow import ALLOWED_UI_TRANSITIONS, UI_STATUSES, internal_to_ui_status, ui_to_internal_status settings = get_settings() @@ -119,7 +121,7 @@ class ArticleUpsertRequest(BaseModel): publish_last_error: str | None = None published_to_wp_at: str | None = None word_count: int = 0 - status: str = Field(default="new", pattern="^(new|rewrite|review|approved|published|error)$") + status: str = Field(default="new", pattern="^(new|rewrite|publish|published|close|review|approved|error)$") meta_json: str | None = None @@ -128,7 +130,7 @@ class IngestionRunRequest(BaseModel): class ArticleTransitionRequest(BaseModel): - target_status: str = Field(pattern="^(new|rewrite|review|approved|published|error)$") + target_status: str = Field(pattern="^(new|rewrite|publish|published|close|review|approved|error)$") note: str | None = None @@ -152,12 +154,11 @@ class PublisherRunRequest(BaseModel): ALLOWED_ARTICLE_TRANSITIONS: dict[str, set[str]] = { - "new": {"review", "rewrite", "error"}, - "rewrite": {"review", "error"}, - "review": {"approved", "rewrite", "error"}, + "new": {"rewrite", "error"}, + "rewrite": {"approved", "error"}, "approved": {"published", "error"}, "published": {"error"}, - "error": {"review", "rewrite"}, + "error": {"rewrite"}, } @@ -340,7 +341,11 @@ def api_finish_run(run_id: int, payload: RunFinishRequest, username: str = Depen @app.get("/api/articles") def api_list_articles(limit: int = 100, status_filter: str | None = None, username: str = Depends(require_auth)) -> dict: - return {"ok": True, "items": repo_list_articles(limit=limit, status_filter=status_filter), "requested_by": username} + internal_filter = ui_to_internal_status(status_filter) if status_filter else None + items = repo_list_articles(limit=limit, status_filter=internal_filter) + for item in items: + item["status_ui"] = internal_to_ui_status(item.get("status")) + return {"ok": True, "items": items, "requested_by": username} @app.get("/api/articles/export") @@ -349,7 +354,8 @@ def api_export_articles( status_filter: str | None = None, username: str = Depends(require_auth), ): - articles = repo_list_articles(limit=500, status_filter=status_filter) + internal_filter = ui_to_internal_status(status_filter) if status_filter else None + articles = repo_list_articles(limit=500, status_filter=internal_filter) rows = [] for article in articles: meta: dict = {} @@ -436,6 +442,7 @@ def api_get_article(article_id: int, username: str = Depends(require_auth)) -> d article = get_article_by_id(article_id) if not article: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Artikel nicht gefunden") + article["status_ui"] = internal_to_ui_status(article.get("status")) return {"ok": True, "item": article, "requested_by": username} @@ -468,7 +475,7 @@ def api_upsert_article(payload: ArticleUpsertRequest, username: str = Depends(re publish_last_error=payload.publish_last_error, published_to_wp_at=payload.published_to_wp_at, word_count=payload.word_count, - status=payload.status, + status=ui_to_internal_status(payload.status), meta_json=payload.meta_json, ) ) @@ -482,22 +489,64 @@ def api_article_transition(article_id: int, payload: ArticleTransitionRequest, u raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Artikel nicht gefunden") current_status = article.get("status") - allowed_targets = ALLOWED_ARTICLE_TRANSITIONS.get(current_status, set()) - if payload.target_status not in allowed_targets: + current_ui = internal_to_ui_status(current_status) + target_internal = ui_to_internal_status(payload.target_status) + target_ui = internal_to_ui_status(target_internal) + allowed_targets = ALLOWED_UI_TRANSITIONS.get(current_ui, set()) + if target_ui not in allowed_targets: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Ungueltiger Statuswechsel: {current_status} -> {payload.target_status}", - ) - if payload.target_status == "published" and int(article.get("legal_checked", 0)) != 1: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Publish gesperrt: Rechtscheck wurde noch nicht freigegeben", + detail=f"Ungueltiger Statuswechsel: {current_ui} -> {target_ui}", ) - updated = update_article_status(article_id, payload.target_status, 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") - return {"ok": True, "id": article_id, "from_status": current_status, "to_status": payload.target_status} + return {"ok": True, "id": article_id, "from_status": current_ui, "to_status": target_ui} + + +@app.post("/api/articles/{article_id}/rewrite-run") +def api_article_rewrite_run(article_id: int, username: str = Depends(require_auth)) -> dict: + article = get_article_by_id(article_id) + if not article: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Artikel nicht gefunden") + if internal_to_ui_status(article.get("status")) not in {"rewrite", "new"}: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Rewrite nur aus Status 'new' oder 'rewrite'") + + rewritten = rewrite_article_text(article) + # upsert via status update + existing fields by lightweight path: + repo_upsert_article( + ArticleUpsert( + feed_id=article.get("feed_id"), + source_article_id=article.get("source_article_id"), + source_hash=article.get("source_hash"), + title=article.get("title"), + source_url=article.get("source_url"), + canonical_url=article.get("canonical_url"), + published_at=article.get("published_at"), + author=article.get("author"), + summary=article.get("summary"), + content_raw=article.get("content_raw"), + content_rewritten=rewritten, + image_urls_json=article.get("image_urls_json"), + press_contact=article.get("press_contact"), + source_name_snapshot=article.get("source_name_snapshot"), + source_terms_url_snapshot=article.get("source_terms_url_snapshot"), + source_license_name_snapshot=article.get("source_license_name_snapshot"), + legal_checked=bool(int(article.get("legal_checked", 0))), + legal_checked_at=article.get("legal_checked_at"), + legal_note=article.get("legal_note"), + wp_post_id=article.get("wp_post_id"), + wp_post_url=article.get("wp_post_url"), + publish_attempts=int(article.get("publish_attempts", 0)), + 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", + meta_json=article.get("meta_json"), + ) + ) + return {"ok": True, "id": article_id, "status": "publish"} @app.post("/api/articles/{article_id}/legal-review") @@ -547,31 +596,7 @@ def api_publisher_run(payload: PublisherRunRequest, username: str = Depends(requ @app.post("/api/articles/{article_id}/review") def api_article_review(article_id: int, payload: ArticleReviewRequest, username: str = Depends(require_auth)) -> dict: - article = get_article_by_id(article_id) - if not article: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Artikel nicht gefunden") - if article.get("status") != "review": - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Review nur fuer Status 'review' erlaubt (aktuell: {article.get('status')})", - ) - - target_status = "approved" if payload.decision == "approve" else "rewrite" - updated = update_article_status( - article_id, - target_status, - actor=username, - note=payload.note, - decision=payload.decision, - ) - if not updated: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Artikel nicht gefunden") - return { - "ok": True, - "id": article_id, - "decision": payload.decision, - "to_status": target_status, - } + raise HTTPException(status_code=status.HTTP_410_GONE, detail="Review-Endpoint ersetzt durch Rewrite-Workflow") @app.post("/api/ingestion/run") diff --git a/backend/app/publisher.py b/backend/app/publisher.py index 06cc8f2..e27bd1b 100644 --- a/backend/app/publisher.py +++ b/backend/app/publisher.py @@ -28,9 +28,7 @@ def enqueue_publish(article_id: int, max_attempts: int = 3) -> int: def _can_publish(article: dict) -> tuple[bool, str | None]: if article.get("status") not in {"approved", "published"}: - return False, "Artikelstatus muss 'approved' sein" - if int(article.get("legal_checked", 0)) != 1: - return False, "Rechtsfreigabe fehlt" + return False, "Artikelstatus muss 'publish' sein" if not selected_image_exists(article): return False, "Hauptbild nicht gesetzt" return True, None diff --git a/backend/app/rewrite.py b/backend/app/rewrite.py new file mode 100644 index 0000000..8c313ad --- /dev/null +++ b/backend/app/rewrite.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import json +import re +from typing import Any +from urllib.request import Request, urlopen + +from .config import get_settings + + +def _sanitize_source_text(text: str) -> str: + raw = (text or "").strip() + if not raw: + return "" + + lines = [ln.strip() for ln in raw.splitlines() if ln.strip()] + if len(lines) > 3: + lines = lines[3:] + + joined = "\n".join(lines) + # Remove press contact block at end from "Pressekontakt" onward. + joined = re.sub( + r"\n?\s*Pressekontakt[\s\S]*$", + "", + joined, + flags=re.IGNORECASE, + ).strip() + return joined + + +def rewrite_article_text(article: dict[str, Any]) -> str: + settings = get_settings() + api_key = settings.openai_api_key + if not api_key: + raise RuntimeError("OPENAI_API_KEY fehlt") + + source_text = _sanitize_source_text(article.get("content_raw") or "") + if not source_text: + source_text = (article.get("summary") or "").strip() + if not source_text: + raise RuntimeError("Kein Quelltext für Rewrite verfügbar") + + title = (article.get("title") or "").strip() + prompt = ( + "Schreibe den folgenden News-Text neu auf Deutsch in persönlicher Du-Form. " + "Stil: ausführlich, gut lesbar, ohne Einleitung mit Datum/Uhrzeit/Firma/Ort, " + "ohne Pressekontakt, ohne Quellenblock. " + "Nutze klare Absätze und Zwischenüberschriften in HTML (
,
{escape(summary)}
\n" if summary else "" @@ -183,9 +195,6 @@ def _build_post_content(article: dict[str, Any]) -> tuple[str, str | None]: if facts else "" ) - press_contact_html = ( - f"{escape(press_contact)}
\n" if press_contact else "" - ) attribution_html = ( "Canonical: {escape(canonical_url)}
\n" attribution_html += "Hinweis: `published` ist erst nach manueller Rechtsfreigabe erlaubt.
+ {% if article.status_ui in ["new", "rewrite"] %} + {% endif %}