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:
parent
9cb53b1e17
commit
b26dc9057c
7 changed files with 613 additions and 89 deletions
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue