User Panel: Phase-8-Abschluss, Titelbild/Lizenzen/Zeitzonen und KI-Pruef-Pipeline

Phase 8 (Rest) + Umbauten vom 10./11.06.:
- Ein Titelbild pro PM (Cover 1280x580), SVG-Platzhalter-Set + Picker,
  PressReleaseCoverImage-Resolver
- Lizenz-/Rechteformular nach "Lizenztyp Bildupload" (7 Lizenztypen,
  Personen-/Sachrechte-Status, bedingte Pflichtfelder, Risikohinweise)
- Veroeffentlichungs-Box vereinfacht (Embargo aus der Form-UI entfernt),
  geplante Termine in Europe/Berlin (Speicherung UTC, DISPLAY_TIMEZONE)
- Quota-Stub (users.press_release_quota) + monatlicher Reset-Command
- Einreichungs-Modal einheitlich in Show/Create/Edit; Ghost-Buttons auf
  filled; PM-Editor-Layout responsive entkoppelt (.pr-editor-layout)

KI-Pruef-Pipeline (Phasen 1-5 des Entwicklungsplans):
- API-Haertung: status nicht mehr per API setzbar, eigene Submit-Route
  durch denselben Funnel (Blacklist, Quota, Status-Log)
- Klassifikation Rot/Gelb/Gruen asynchron (Queue classification,
  OpenAI-Treiber + deterministischer Fallback), ki_audits-Audit-Log
- Routing: Rot -> rejected + Mail, Gelb -> Review-Queue, Gruen ->
  Auto-Publish; Scheduler publiziert nur gruene faellige PMs
- Content-Score 0-100 -> Stufe (Standard/Geprueft/Hochwertig) inkl.
  Editor-Panel und Badges; Re-Klassifikation/-Score bei Aenderung
- Admin: KI-Badge + Filter, On-Demand-Pruefung mit Anbieter-Override

Suite: 442 passed, 4 skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-06-12 08:30:13 +00:00
parent 0efabaf446
commit a000238ca8
141 changed files with 5922 additions and 1001 deletions

View file

@ -0,0 +1,33 @@
<?php
namespace App\Services\PressRelease\Classification;
use App\Services\PressRelease\Classification\Contracts\ClassificationDriver;
use App\Services\PressRelease\Classification\Drivers\DeterministicClassificationDriver;
use App\Services\PressRelease\Classification\Drivers\OpenAiClassificationDriver;
use Illuminate\Support\Manager;
/**
* Löst den aktiven Klassifikations-Treiber anhand von
* config('scoring.classification.provider') auf (Laravel-Manager-Pattern).
*
* Weitere Anbieter (Anthropic, Gemini) werden später als zusätzliche
* create*Driver()-Methoden ergänzt.
*/
class ClassificationManager extends Manager
{
public function getDefaultDriver(): string
{
return (string) ($this->config->get('scoring.classification.provider') ?: 'deterministic');
}
public function createDeterministicDriver(): ClassificationDriver
{
return $this->container->make(DeterministicClassificationDriver::class);
}
public function createOpenaiDriver(): ClassificationDriver
{
return $this->container->make(OpenAiClassificationDriver::class);
}
}

View file

@ -0,0 +1,31 @@
<?php
namespace App\Services\PressRelease\Classification;
use App\Enums\PressReleaseClassification;
/**
* Ergebnis einer KI-Klassifikation (Red Flag, Konzept §15.1).
*
* Provider-neutral: jeder Treiber liefert dasselbe Schema zurück, damit der
* Job das Ergebnis einheitlich persistieren und auditieren kann.
*/
final class ClassificationResult
{
/**
* @param list<string> $reasons Begründungen der KI (kann leer sein)
* @param array<string, mixed> $rawResponse Roh-Antwort für das Audit-Log
*/
public function __construct(
public readonly PressReleaseClassification $classification,
public readonly array $reasons,
public readonly string $provider,
public readonly ?string $model,
public readonly array $rawResponse,
) {}
public function reasonText(): ?string
{
return $this->reasons === [] ? null : implode('; ', $this->reasons);
}
}

View file

@ -0,0 +1,21 @@
<?php
namespace App\Services\PressRelease\Classification\Contracts;
use App\Models\PressRelease;
use App\Services\PressRelease\Classification\ClassificationResult;
/**
* Vertrag für austauschbare Klassifikations-Treiber (OpenAI, Anthropic,
* Gemini, deterministischer Fallback). Auswahl über config/scoring.php.
*/
interface ClassificationDriver
{
/**
* Klassifiziert eine Pressemitteilung (green/yellow/red).
*
* Wirft bei API-Fehlern/Timeouts eine Exception; der aufrufende Job
* weicht dann auf den deterministischen Fallback aus.
*/
public function classify(PressRelease $pressRelease): ClassificationResult;
}

View file

@ -0,0 +1,44 @@
<?php
namespace App\Services\PressRelease\Classification\Drivers;
use App\Enums\PressReleaseClassification;
use App\Models\PressRelease;
use App\Services\PressRelease\BlacklistService;
use App\Services\PressRelease\Classification\ClassificationResult;
use App\Services\PressRelease\Classification\Contracts\ClassificationDriver;
/**
* Regelbasierter Fallback ohne externe API.
*
* Greift, wenn kein KI-Anbieter konfiguriert ist oder die KI ausfällt
* (Timeout, Rate-Limit, Fehler). Bewertet ausschließlich über die
* Wort-Blacklist: Treffer Rot, sonst Grün. Liefert nie Gelb.
*/
class DeterministicClassificationDriver implements ClassificationDriver
{
public function __construct(private readonly BlacklistService $blacklist) {}
public function classify(PressRelease $pressRelease): ClassificationResult
{
$word = $this->blacklist->findInPressRelease($pressRelease);
if ($word !== null) {
return new ClassificationResult(
classification: PressReleaseClassification::Red,
reasons: [sprintf('Unzulässiges Wort gefunden: "%s".', $word)],
provider: 'deterministic',
model: null,
rawResponse: ['matched_word' => $word],
);
}
return new ClassificationResult(
classification: PressReleaseClassification::Green,
reasons: [],
provider: 'deterministic',
model: null,
rawResponse: ['matched_word' => null],
);
}
}

View file

@ -0,0 +1,115 @@
<?php
namespace App\Services\PressRelease\Classification\Drivers;
use App\Enums\PressReleaseClassification;
use App\Models\PressRelease;
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
{
return <<<'PROMPT'
Du bist ein redaktioneller Prüf-Assistent für ein deutsches Presseportal.
Bewerte, ob eine eingereichte Pressemitteilung veröffentlicht werden darf.
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.
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
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.
PROMPT;
}
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}";
}
}