Admin-PM-Liste: KI-Pruefungs-Modal + geschaerfte KI-Prompts

- Spalten umsortiert (Aktionen, Datum, KI-Pruefung zuerst); Pruefen-Button
  nur noch Icon, oeffnet Detail-Modal mit Klassifikation, Begruendung,
  Content-Score inkl. Faktor-Breakdown und Re-Check (wire:poll bis Queue durch)
- Content-Score: Bild-Punkte (10) systemseitig statt KI (nur ein Upload-Bild
  moeglich), Quellen 10->5, Pressestil 20->25; Breakdown wandert ins Audit-Log
- Klassifikations-Prompt prueft gegen aktive Ablehnungskategorien (K1-K6,
  rejection_categories): klarer Verstoss => direkt rot mit Kategorie-Code

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Kevin Adametz 2026-07-10 16:37:47 +00:00
parent 9cb53b1e17
commit b26dc9057c
7 changed files with 613 additions and 89 deletions

View file

@ -57,7 +57,7 @@ class ScorePressRelease implements ShouldQueue
'model' => $result->model,
'result' => (string) $result->score,
'reason' => $tier->label(),
'raw_response' => $result->rawResponse,
'raw_response' => ['breakdown' => $result->breakdown] + $result->rawResponse,
'created_at' => now(),
]);
}

View file

@ -4,6 +4,7 @@ namespace App\Services\PressRelease\Classification\Drivers;
use App\Enums\PressReleaseClassification;
use App\Models\PressRelease;
use App\Models\RejectionCategory;
use App\Services\PressRelease\Classification\ClassificationResult;
use App\Services\PressRelease\Classification\Contracts\ClassificationDriver;
use Illuminate\Support\Facades\Http;
@ -82,29 +83,84 @@ class OpenAiClassificationDriver implements ClassificationDriver
private function systemPrompt(): string
{
return <<<'PROMPT'
Du bist ein redaktioneller Prüf-Assistent für ein deutsches Presseportal.
Bewerte, ob eine eingereichte Pressemitteilung veröffentlicht werden darf.
$categoryBlock = $this->rejectionCategoryBlock();
Klassifiziere genau eine der drei Stufen:
- "green": unauffällig, kann veröffentlicht werden.
- "yellow": grenzwertig/unklar, sollte manuell geprüft werden.
- "red": unzulässig, darf nicht veröffentlicht werden.
return <<<PROMPT
Du bist ein redaktioneller Prüf-Assistent für ein deutsches Presseportal
(Pressemitteilungen von Unternehmen). Deine Aufgabe ist ausschließlich
die Zulässigkeitsprüfung nach den Veröffentlichungsrichtlinien des
Portals — nicht die Bewertung der handwerklichen Qualität.
Prüfe insbesondere diese Faktoren (Red Flags):
- reine Werbung statt journalistischer Pressemitteilung
- beleidigende, diskriminierende oder hetzerische Inhalte
- rechtlich heikle Aussagen (z. B. unbelegte Heil-/Gewinnversprechen,
Verleumdung, Aufruf zu Straftaten)
- Spam-Muster (sinnlose Keyword-Wiederholung, irreführende Links)
- unseriöse oder offensichtlich falsche Versprechen
## Ablehnungskategorien (Richtlinien)
{$categoryBlock}
## Klassifikationsstufen
- "red": Der Inhalt verstößt klar erkennbar gegen mindestens eine der
Ablehnungskategorien. Ein einziger eindeutiger Verstoß genügt —
markiere die Mitteilung dann direkt rot, unabhängig davon, wie
seriös der Rest wirkt.
- "yellow": Es gibt konkrete Anhaltspunkte für einen möglichen Verstoß,
die sich aus dem Text allein nicht sicher belegen lassen (z. B.
zweifelhafte Tatsachenbehauptungen ohne Quelle, stark werblicher Ton
mit geringem Nachrichtenwert, mögliche Rechteprobleme). Auch wenn du
zwischen zwei Stufen schwankst, wähle "yellow".
- "green": Keine Verstöße und keine konkreten Verdachtsmomente. Eine
journalistisch gebaute Unternehmensmeldung mit erkennbarem
Nachrichtenwert ist "green", auch wenn sie das Unternehmen positiv
darstellt — Eigen-PR ist normal und kein Ablehnungsgrund.
## Entscheidungsregeln
1. Prüfe Titel und Text vollständig gegen jede Kategorie.
2. "red" nur bei einem klar belegbaren Verstoß; benenne dann die
Kategorie (Code) und zitiere die entscheidende Passage wörtlich
oder sinngemäß.
3. Unterscheide werbliche Sprache (zulässig, ggf. "yellow" bei reinem
Verkaufstext ohne Nachrichtenwert) von unzulässigen Inhalten.
4. Superlative und Meinungen sind zulässig; unbelegte Tatsachen-,
Heil- oder Gewinnversprechen sind es nicht.
5. Bewerte nur, was im Text steht — unterstelle nichts, was nicht
belegbar ist.
## Antwortformat
Antworte AUSSCHLIESSLICH als JSON-Objekt in genau diesem Schema:
{"classification": "green|yellow|red", "reasons": ["kurze Begründung", ...]}
Bei "green" darf "reasons" leer sein. Schreibe Begründungen auf Deutsch.
{"classification": "green|yellow|red", "reasons": ["<Kategorie-Code>: kurze Begründung mit Textbeleg", ...]}
Bei "green" darf "reasons" leer sein. Schreibe Begründungen auf Deutsch,
maximal 2 Sätze pro Begründung.
PROMPT;
}
/**
* Aktive Ablehnungskategorien aus der Datenbank (Richtlinien v0.1) als
* Prompt-Abschnitt; Fallback auf eine generische Liste, falls die
* Kategorien (noch) nicht geseedet sind.
*/
private function rejectionCategoryBlock(): string
{
$categories = RejectionCategory::query()
->active()
->ordered()
->get(['code', 'public_label', 'public_desc']);
if ($categories->isEmpty()) {
return <<<'FALLBACK'
- K1 Rechtswidrige Inhalte: strafbare Inhalte, Verstöße gegen Wettbewerbs- oder Heilmittelwerberecht, verbotene Produkte.
- K2 Explizite oder jugendgefährdende Inhalte: pornografische oder gewaltverherrlichende Darstellungen.
- K3 Hass und Diskriminierung: Herabwürdigung von Menschen wegen Herkunft, Religion, Geschlechtsidentität o. ä.
- K4 Irreführung und Falschbehauptung: nachweislich falsche Behauptungen, erfundene Studien, Heilsversprechen ohne Grundlage.
- K5 Rechte Dritter: Urheber-, Marken- oder Persönlichkeitsrechtsverletzungen, herabsetzende Aussagen über Dritte.
- K6 Kein Pressecharakter / Spam: reine Verkaufstexte ohne Nachrichtenwert, Keyword-Spam, Masseninhalte, Duplikate.
FALLBACK;
}
return $categories
->map(fn (RejectionCategory $category): string => "- {$category->code} {$category->public_label}: {$category->public_desc}")
->implode("\n");
}
private function userPrompt(PressRelease $pressRelease): string
{
$title = (string) $pressRelease->title;

View file

@ -18,6 +18,15 @@ use RuntimeException;
*/
class OpenAiContentScoreDriver implements ContentScoreDriver
{
/**
* Bildmaterial wird nicht von der KI bewertet: Im Artikel selbst ist kein
* Bild möglich, es gibt nur das eine Upload-Bild. Vorhanden = volle
* Punktzahl (Entscheidung 10.07.2026); die KI vergibt die restlichen 90.
*/
public const IMAGE_POINTS = 10;
public const MAX_AI_SCORE = 100 - self::IMAGE_POINTS;
public function score(PressRelease $pressRelease): ContentScoreResult
{
$openai = config('services.openai');
@ -59,8 +68,12 @@ class OpenAiContentScoreDriver implements ContentScoreDriver
throw new RuntimeException('OpenAI-Antwort war kein gültiges Score-JSON.');
}
$score = (int) max(0, min(100, (int) round((float) $parsed['score'])));
$aiScore = (int) max(0, min(self::MAX_AI_SCORE, (int) round((float) $parsed['score'])));
$imagePoints = $pressRelease->images()->count() > 0 ? self::IMAGE_POINTS : 0;
$score = (int) min(100, $aiScore + $imagePoints);
$breakdown = is_array($parsed['breakdown'] ?? null) ? $parsed['breakdown'] : [];
$breakdown['bild'] = $imagePoints;
return new ContentScoreResult(
score: $score,
@ -75,23 +88,23 @@ class OpenAiContentScoreDriver implements ContentScoreDriver
{
return <<<'PROMPT'
Du bewertest die handwerkliche Qualität einer deutschen Pressemitteilung
auf einer Skala von 0 bis 100 (Content-Score). Bewerte ausschließlich die
Qualität, nicht die Zulässigkeit.
auf einer Skala von 0 bis 90 (Content-Score). Bewerte ausschließlich die
Qualität, nicht die Zulässigkeit. Bildmaterial wird separat vom System
bewertet und ist NICHT Teil deiner Bewertung.
Berücksichtige diese gewichteten Kategorien:
- Pressestil (20%): informativ statt werblich, aktive Sprache, Zitate
- Struktur (15%): Lead-Absatz, sinnvolle Absätze, pyramidaler Aufbau
- Lesbarkeit (10%): Satzlängen, angemessene Fachsprache
- Vollständigkeit (15%): Pressekontakt, Unternehmensinfo, Datum, Region
- Bildmaterial (10%): Bild vorhanden/erwähnt
- Quellen/Belege (10%): Verlinkungen, Studien, Datenquellen
- Headline-Stärke (10%): Länge, Klarheit, Keyword-Relevanz
- Originalität (10%): kein Boilerplate, individueller Ton
- Pressestil (25 Punkte): informativ statt werblich, aktive Sprache, Zitate
- Struktur (15 Punkte): Lead-Absatz, sinnvolle Absätze, pyramidaler Aufbau
- Lesbarkeit (10 Punkte): Satzlängen, angemessene Fachsprache
- Vollständigkeit (15 Punkte): Pressekontakt, Unternehmensinfo, Datum, Region
- Quellen/Belege (5 Punkte): Verlinkungen, Studien, Datenquellen
- Headline-Stärke (10 Punkte): Länge, Klarheit, Keyword-Relevanz
- Originalität (10 Punkte): kein Boilerplate, individueller Ton
Antworte AUSSCHLIESSLICH als JSON-Objekt in genau diesem Schema:
{"score": 0-100, "breakdown": {"pressestil": 0-20, "struktur": 0-15,
"lesbarkeit": 0-10, "vollstaendigkeit": 0-15, "bild": 0-10,
"quellen": 0-10, "headline": 0-10, "originalitaet": 0-10}}
{"score": 0-90, "breakdown": {"pressestil": 0-25, "struktur": 0-15,
"lesbarkeit": 0-10, "vollstaendigkeit": 0-15,
"quellen": 0-5, "headline": 0-10, "originalitaet": 0-10}}
PROMPT;
}
@ -99,9 +112,8 @@ class OpenAiContentScoreDriver implements ContentScoreDriver
{
$title = (string) $pressRelease->title;
$text = trim(strip_tags((string) $pressRelease->text));
$hasImage = $pressRelease->images()->count() > 0 ? 'ja' : 'nein';
$source = filled($pressRelease->backlink_url) ? $pressRelease->backlink_url : '—';
return "Titel:\n{$title}\n\nBild vorhanden: {$hasImage}\nQuelle/Link: {$source}\n\nText:\n{$text}";
return "Titel:\n{$title}\n\nQuelle/Link: {$source}\n\nText:\n{$text}";
}
}