presseportale/app/Services/PressRelease/Classification/Drivers/OpenAiClassificationDriver.php
Kevin Adametz 06d80b255b KI-Klassifikation: Substanzpruefung gegen Gruen-Durchrutscher
Gruen setzt jetzt einen erkennbaren Nachrichtenkern voraus; Test-/
Platzhalter-Texte sind explizit K6-Verstoss (rot), sehr duenne Inhalte
mindestens gelb. Behebt Befund, dass inhaltsleere Test-PMs (Score 4)
nach der Prompt-Ueberarbeitung gruen durchliefen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 17:14:12 +00:00

181 lines
8 KiB
PHP

<?php
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;
use RuntimeException;
/**
* Klassifikations-Treiber auf Basis der OpenAI Chat-Completions-API.
*
* Liest Zugangsdaten/Modell aus config/services.openai. Verlangt eine
* strukturierte JSON-Antwort `{classification, reasons[]}`. Bei fehlendem
* Key, Transport-/HTTP-Fehlern oder ungültiger Antwort wird eine Exception
* geworfen, sodass der Job auf den deterministischen Treiber ausweicht.
*/
class OpenAiClassificationDriver implements ClassificationDriver
{
public function classify(PressRelease $pressRelease): ClassificationResult
{
$config = config('services.openai');
$apiKey = $config['api_key'] ?? null;
if (blank($apiKey)) {
throw new RuntimeException('OpenAI API-Key ist nicht konfiguriert.');
}
$model = config('scoring.classification.model') ?: ($config['model'] ?? 'gpt-5.4-mini');
$timeout = (int) (config('scoring.classification.timeout') ?: ($config['timeout'] ?? 30));
$response = Http::withToken($apiKey)
->timeout($timeout)
->acceptJson()
->post($config['url'] ?? 'https://api.openai.com/v1/chat/completions', [
'model' => $model,
'response_format' => ['type' => 'json_object'],
'messages' => [
['role' => 'system', 'content' => $this->systemPrompt()],
['role' => 'user', 'content' => $this->userPrompt($pressRelease)],
],
]);
if ($response->failed()) {
throw new RuntimeException("OpenAI-Klassifikation fehlgeschlagen (HTTP {$response->status()}).");
}
$payload = $response->json();
$content = data_get($payload, 'choices.0.message.content');
if (! is_string($content) || trim($content) === '') {
throw new RuntimeException('OpenAI-Antwort enthielt keinen Inhalt.');
}
$parsed = json_decode($content, true);
if (! is_array($parsed) || ! isset($parsed['classification'])) {
throw new RuntimeException('OpenAI-Antwort war kein gültiges Klassifikations-JSON.');
}
$classification = PressReleaseClassification::tryFrom((string) $parsed['classification']);
if ($classification === null) {
throw new RuntimeException('OpenAI lieferte einen unbekannten Klassifikationswert.');
}
$reasons = array_values(array_filter(array_map(
static fn ($reason): string => (string) $reason,
is_array($parsed['reasons'] ?? null) ? $parsed['reasons'] : [],
)));
return new ClassificationResult(
classification: $classification,
reasons: $reasons,
provider: 'openai',
model: $model,
rawResponse: is_array($payload) ? $payload : [],
);
}
private function systemPrompt(): string
{
$categoryBlock = $this->rejectionCategoryBlock();
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.
## 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, sehr dünner
Inhalt mit kaum erkennbarer Nachricht). Auch wenn du zwischen zwei
Stufen schwankst, wähle "yellow".
- "green": Keine Verstöße und keine konkreten Verdachtsmomente. "green"
setzt zwingend voraus, dass der Text eine echte, substanzielle
Pressemitteilung mit erkennbarem Nachrichtenkern ist (wer teilt was
mit?). Eigen-PR mit positivem Unternehmensbild ist dabei normal und
kein Ablehnungsgrund.
## Entscheidungsregeln
1. Prüfe zuerst die Substanz: Test-, Platzhalter- oder inhaltsleere
Texte (z. B. "Test", Lorem ipsum, zusammenhanglose Füllsätze, nur
wenige Sätze ohne jede Aussage) sind ein klarer Verstoß gegen
"Kein Pressecharakter / Spam" und damit "red" — solche Mitteilungen
dürfen nie "green" sein.
2. Sehr dünne Inhalte, die zwar echt wirken, aber keinen erkennbaren
Nachrichtenwert haben, sind mindestens "yellow".
3. Prüfe Titel und Text vollständig gegen jede Kategorie.
4. "red" nur bei einem klar belegbaren Verstoß; benenne dann die
Kategorie (Code) und zitiere die entscheidende Passage wörtlich
oder sinngemäß.
5. Unterscheide werbliche Sprache (zulässig, ggf. "yellow" bei reinem
Verkaufstext ohne Nachrichtenwert) von unzulässigen Inhalten.
6. Superlative und Meinungen sind zulässig; unbelegte Tatsachen-,
Heil- oder Gewinnversprechen sind es nicht.
7. Bewerte nur, was im Text steht — unterstelle keine Verstöße, die
nicht belegbar sind. Fehlende Substanz ist dagegen direkt am Text
erkennbar und zählt als Beleg.
## Antwortformat
Antworte AUSSCHLIESSLICH als JSON-Objekt in genau diesem Schema:
{"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;
$text = trim(strip_tags((string) $pressRelease->text));
return "Titel:\n{$title}\n\nText:\n{$text}";
}
}