feat(workflow): simplify article flow and add automated rewrite step
This commit is contained in:
parent
8d7375c99f
commit
35ccceb260
11 changed files with 332 additions and 107 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
83
backend/app/rewrite.py
Normal file
83
backend/app/rewrite.py
Normal file
|
|
@ -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 (<h2>, <p>, <ul><li> falls passend). "
|
||||
"Inhaltlich korrekt bleiben, nichts erfinden.\n\n"
|
||||
f"Titel: {title}\n\n"
|
||||
f"Originaltext:\n{source_text}"
|
||||
)
|
||||
|
||||
payload = {
|
||||
"model": settings.openai_model,
|
||||
"temperature": 0.4,
|
||||
"messages": [
|
||||
{"role": "system", "content": "Du bist ein deutscher News-Redakteur."},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
}
|
||||
req = Request(
|
||||
url="https://api.openai.com/v1/chat/completions",
|
||||
method="POST",
|
||||
data=json.dumps(payload).encode("utf-8"),
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
with urlopen(req, timeout=60) as resp:
|
||||
raw = resp.read().decode("utf-8", errors="replace")
|
||||
data = json.loads(raw)
|
||||
choices = data.get("choices")
|
||||
if not isinstance(choices, list) or not choices:
|
||||
raise RuntimeError(f"Ungültige OpenAI-Antwort: {data}")
|
||||
message = choices[0].get("message", {})
|
||||
content = message.get("content")
|
||||
if not isinstance(content, str) or not content.strip():
|
||||
raise RuntimeError("OpenAI lieferte keinen Rewrite-Text")
|
||||
return content.strip()
|
||||
|
||||
|
|
@ -143,11 +143,24 @@ def _as_paragraph_html(text: str) -> str:
|
|||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _sanitize_publish_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:]
|
||||
merged = "\n".join(lines)
|
||||
merged = re.sub(r"\n?\s*Pressekontakt[\s\S]*$", "", merged, flags=re.IGNORECASE).strip()
|
||||
return merged
|
||||
|
||||
|
||||
def _build_post_content(article: dict[str, Any]) -> tuple[str, str | None]:
|
||||
source_url = article.get("source_url") or ""
|
||||
canonical_url = article.get("canonical_url") or source_url
|
||||
summary = (article.get("summary") or "").strip()
|
||||
body_text = (article.get("content_rewritten") or article.get("content_raw") or "").strip()
|
||||
body_text = _sanitize_publish_text(body_text)
|
||||
if not body_text:
|
||||
body_text = summary
|
||||
|
||||
|
|
@ -162,7 +175,6 @@ def _build_post_content(article: dict[str, Any]) -> tuple[str, str | None]:
|
|||
source_name = (article.get("source_name_snapshot") or "").strip()
|
||||
license_name = (article.get("source_license_name_snapshot") or "").strip()
|
||||
terms_url = (article.get("source_terms_url_snapshot") or "").strip()
|
||||
press_contact = (article.get("press_contact") or "").strip()
|
||||
|
||||
lead_html = f"<p><em>{escape(summary)}</em></p>\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"<h3>Pressekontakt</h3>\n<p>{escape(press_contact)}</p>\n" if press_contact else ""
|
||||
)
|
||||
attribution_html = (
|
||||
"<hr />\n<section class=\"rss-news-attribution\">\n"
|
||||
"<h3>Quelle</h3>\n"
|
||||
|
|
@ -195,7 +204,7 @@ def _build_post_content(article: dict[str, Any]) -> tuple[str, str | None]:
|
|||
attribution_html += f"<p>Canonical: <a href=\"{escape(canonical_url)}\">{escape(canonical_url)}</a></p>\n"
|
||||
attribution_html += "</section>"
|
||||
|
||||
content = f"{lead_html}{body_html}\n\n{facts_html}{press_contact_html}{attribution_html}".strip()
|
||||
content = f"{lead_html}{body_html}\n\n{facts_html}{attribution_html}".strip()
|
||||
excerpt_source = summary or re.sub(r"\s+", " ", body_text).strip()
|
||||
excerpt = excerpt_source[:220] if excerpt_source else None
|
||||
return content, excerpt
|
||||
|
|
|
|||
39
backend/app/workflow.py
Normal file
39
backend/app/workflow.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
from __future__ import annotations
|
||||
|
||||
UI_STATUSES = ("new", "rewrite", "publish", "published", "close")
|
||||
|
||||
|
||||
def internal_to_ui_status(status: str | None) -> str:
|
||||
value = (status or "").strip()
|
||||
if value == "approved":
|
||||
return "publish"
|
||||
if value == "error":
|
||||
return "close"
|
||||
if value == "review":
|
||||
return "rewrite"
|
||||
if value in {"new", "rewrite", "published"}:
|
||||
return value
|
||||
return value or "new"
|
||||
|
||||
|
||||
def ui_to_internal_status(status: str | None) -> str:
|
||||
value = (status or "").strip()
|
||||
if value == "publish":
|
||||
return "approved"
|
||||
if value == "close":
|
||||
return "error"
|
||||
if value in {"new", "rewrite", "published"}:
|
||||
return value
|
||||
if value in {"approved", "error", "review"}:
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
ALLOWED_UI_TRANSITIONS: dict[str, set[str]] = {
|
||||
"new": {"rewrite", "close"},
|
||||
"rewrite": {"publish", "close"},
|
||||
"publish": {"published", "close"},
|
||||
"published": {"close"},
|
||||
"close": {"rewrite"},
|
||||
}
|
||||
|
||||
|
|
@ -30,7 +30,7 @@
|
|||
<section class="card">
|
||||
<h2>{{ article.title }}</h2>
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item"><span class="k">Status</span><span><span class="badge">{{ article.status }}</span></span></div>
|
||||
<div class="detail-item"><span class="k">Status</span><span><span class="badge">{{ article.status_ui }}</span></span></div>
|
||||
<div class="detail-item"><span class="k">Artikel-Datum</span><span>{{ article.published_at or "-" }}</span></div>
|
||||
<div class="detail-item"><span class="k">Alter</span><span>{{ article.days_old if article.days_old is not none else "-" }} Tage</span></div>
|
||||
<div class="detail-item"><span class="k">Relevanz</span><span>{{ article.relevance }}</span></div>
|
||||
|
|
@ -187,8 +187,10 @@
|
|||
|
||||
<section class="card">
|
||||
<h2>Status ändern</h2>
|
||||
{% if not article.legal_checked %}
|
||||
<p class="subtle">Hinweis: `published` ist erst nach manueller Rechtsfreigabe erlaubt.</p>
|
||||
{% if article.status_ui in ["new", "rewrite"] %}
|
||||
<form method="post" action="/admin/articles/{{ article.id }}/rewrite-run" class="row" style="margin-bottom:8px;">
|
||||
<button type="submit">Rewrite ausführen (OpenAI)</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="post" action="/admin/articles/{{ article.id }}/transition" class="row">
|
||||
<select name="target_status">
|
||||
|
|
@ -215,6 +217,7 @@
|
|||
</ul>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<p class="subtle">Voraussetzungen: Status `publish` und Hauptbild gesetzt.</p>
|
||||
<form method="post" action="/admin/articles/{{ article.id }}/publish-enqueue" class="row">
|
||||
<input name="max_attempts" value="3" />
|
||||
<button type="submit" {% if not article.publish_ready %}disabled{% endif %}>In Queue einreihen</button>
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@
|
|||
</form>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>ID</th><th>Artikel</th><th>Status</th><th>Details</th><th>Review</th><th>Transition</th></tr>
|
||||
<tr><th>ID</th><th>Artikel</th><th>Status</th><th>Details</th><th>Rewrite</th><th>Transition</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for a in articles %}
|
||||
|
|
@ -160,7 +160,7 @@
|
|||
<br /><a href="{{ a.canonical_url }}" target="_blank" rel="noopener">Canonical öffnen</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><span class="badge">{{ a.status }}</span></td>
|
||||
<td><span class="badge">{{ a.status_ui }}</span></td>
|
||||
<td>
|
||||
<div class="subtle">Legal: {{ "OK" if a.legal_checked else "offen" }}</div>
|
||||
<div class="subtle">Publish: {{ "bereit" if a.publish_ready else "blockiert" }}</div>
|
||||
|
|
@ -202,11 +202,9 @@
|
|||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if a.status == "review" %}
|
||||
<form method="post" action="/admin/articles/{{ a.id }}/review" class="inline">
|
||||
<input name="note" placeholder="Notiz" />
|
||||
<button name="decision" value="approve" type="submit">Approve</button>
|
||||
<button name="decision" value="reject" type="submit" class="secondary">Reject</button>
|
||||
{% if a.status_ui in ["new", "rewrite"] %}
|
||||
<form method="post" action="/admin/articles/{{ a.id }}/rewrite-run" class="inline">
|
||||
<button type="submit">Rewrite ausführen</button>
|
||||
</form>
|
||||
{% else %}
|
||||
-
|
||||
|
|
@ -215,11 +213,11 @@
|
|||
<td>
|
||||
<form method="post" action="/admin/articles/{{ a.id }}/transition" class="inline">
|
||||
<select name="target_status">
|
||||
{% for s in allowed_transitions.get(a.status, []) %}
|
||||
{% for s in allowed_transitions.get(a.status_ui, []) %}
|
||||
<option value="{{ s }}">{{ s }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% if allowed_transitions.get(a.status, []) %}
|
||||
{% if allowed_transitions.get(a.status_ui, []) %}
|
||||
<button type="submit" class="secondary">Setzen</button>
|
||||
{% else %}
|
||||
<span class="subtle">keine Aktion</span>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import unittest
|
|||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from unittest.mock import patch
|
||||
|
||||
from backend.app import config as config_module
|
||||
from backend.app.db import init_db
|
||||
|
|
@ -66,39 +67,40 @@ class TestArticleWorkflow(unittest.TestCase):
|
|||
def test_valid_transition_chain(self) -> None:
|
||||
article_id = self._create_article()
|
||||
|
||||
t1 = self.client.post(f"/api/articles/{article_id}/transition", json={"target_status": "review"})
|
||||
t1 = self.client.post(f"/api/articles/{article_id}/transition", json={"target_status": "rewrite"})
|
||||
self.assertEqual(t1.status_code, 200)
|
||||
|
||||
r1 = self.client.post(f"/api/articles/{article_id}/review", json={"decision": "approve", "note": "ok"})
|
||||
self.assertEqual(r1.status_code, 200)
|
||||
self.assertEqual(r1.json()["to_status"], "approved")
|
||||
|
||||
blocked_publish = self.client.post(f"/api/articles/{article_id}/transition", json={"target_status": "published"})
|
||||
self.assertEqual(blocked_publish.status_code, 400)
|
||||
|
||||
legal = self.client.post(
|
||||
f"/api/articles/{article_id}/legal-review",
|
||||
json={"approved": True, "note": "Rechte geprueft"},
|
||||
)
|
||||
self.assertEqual(legal.status_code, 200)
|
||||
|
||||
t2 = self.client.post(f"/api/articles/{article_id}/transition", json={"target_status": "published"})
|
||||
t2 = self.client.post(f"/api/articles/{article_id}/transition", json={"target_status": "publish"})
|
||||
self.assertEqual(t2.status_code, 200)
|
||||
|
||||
t3 = self.client.post(f"/api/articles/{article_id}/transition", json={"target_status": "published"})
|
||||
self.assertEqual(t3.status_code, 200)
|
||||
|
||||
final = self.client.get(f"/api/articles/{article_id}")
|
||||
self.assertEqual(final.status_code, 200)
|
||||
self.assertEqual(final.json()["item"]["status"], "published")
|
||||
self.assertEqual(final.json()["item"]["legal_checked"], 1)
|
||||
self.assertEqual(final.json()["item"]["status_ui"], "published")
|
||||
|
||||
def test_invalid_transition_rejected(self) -> None:
|
||||
article_id = self._create_article()
|
||||
bad = self.client.post(f"/api/articles/{article_id}/transition", json={"target_status": "published"})
|
||||
self.assertEqual(bad.status_code, 400)
|
||||
|
||||
def test_review_only_allowed_in_review_status(self) -> None:
|
||||
def test_legacy_review_endpoint_is_gone(self) -> None:
|
||||
article_id = self._create_article()
|
||||
bad = self.client.post(f"/api/articles/{article_id}/review", json={"decision": "approve"})
|
||||
self.assertEqual(bad.status_code, 400)
|
||||
self.assertEqual(bad.status_code, 410)
|
||||
|
||||
@patch("backend.app.main.rewrite_article_text")
|
||||
def test_rewrite_run_sets_publish_status(self, mock_rewrite) -> None:
|
||||
mock_rewrite.return_value = "<h2>Neu</h2><p>Umschreibung</p>"
|
||||
article_id = self._create_article()
|
||||
self.client.post(f"/api/articles/{article_id}/transition", json={"target_status": "rewrite"})
|
||||
r = self.client.post(f"/api/articles/{article_id}/rewrite-run")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual(r.json()["status"], "publish")
|
||||
final = self.client.get(f"/api/articles/{article_id}")
|
||||
self.assertEqual(final.json()["item"]["status_ui"], "publish")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -62,6 +62,24 @@ class TestWordpressPublish(unittest.TestCase):
|
|||
self.assertNotIn("featured_media", payload)
|
||||
self.assertIn("<p>Inhalt</p>", payload.get("content", ""))
|
||||
|
||||
@patch("backend.app.wordpress._upload_featured_media")
|
||||
@patch("backend.app.wordpress._wp_request")
|
||||
def test_publish_strips_feed_header_and_press_contact(self, mock_wp_request, mock_upload_media) -> None:
|
||||
mock_wp_request.return_value = {"id": 100, "link": "https://example.org/?p=100"}
|
||||
article = {
|
||||
"title": "Header Test",
|
||||
"content_raw": "21.02.2026 10:00\nFirma GmbH\n(ots)\nDas ist der eigentliche Text.\nPressekontakt: Test Person",
|
||||
"source_url": "https://example.com/source",
|
||||
"canonical_url": "https://example.com/source",
|
||||
"meta_json": "{}",
|
||||
}
|
||||
publish_article_draft(article)
|
||||
payload = mock_wp_request.call_args.kwargs["payload"]
|
||||
content = payload.get("content", "")
|
||||
self.assertNotIn("Firma GmbH", content)
|
||||
self.assertNotIn("Pressekontakt", content)
|
||||
self.assertIn("eigentliche Text", content)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue