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:
parent
0efabaf446
commit
a000238ca8
141 changed files with 5922 additions and 1001 deletions
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Services\Customer;
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Models\Company;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
|
@ -78,6 +79,30 @@ class CustomerCompanyContext
|
|||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Company>
|
||||
*/
|
||||
public function searchCompaniesFor(User $user, string $term = '', ?int $selectedCompanyId = null, int $limit = 10): Collection
|
||||
{
|
||||
$term = Portal::stripTrailingAbbreviation($term);
|
||||
$limit = max(1, $limit);
|
||||
|
||||
if ($term === '') {
|
||||
return $this->companyOptionsWithSelected($user, $selectedCompanyId, $limit);
|
||||
}
|
||||
|
||||
return $this->companyOptionQuery($user)
|
||||
->where(function (Builder $query) use ($term): void {
|
||||
$query->where('companies.name', 'like', '%'.$term.'%')
|
||||
->orWhere('companies.slug', 'like', '%'.$term.'%')
|
||||
->orWhere('companies.email', 'like', '%'.$term.'%');
|
||||
})
|
||||
->orderBy('companies.name')
|
||||
->orderBy('companies.id')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
public function companyCountFor(User $user): int
|
||||
{
|
||||
return $this->accessibleCompanyQuery($user)->count();
|
||||
|
|
@ -181,6 +206,41 @@ class CustomerCompanyContext
|
|||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Builder<Company>
|
||||
*/
|
||||
private function companyOptionQuery(User $user): Builder
|
||||
{
|
||||
return $this->accessibleCompanyQuery($user)
|
||||
->select(['companies.id', 'companies.name', 'companies.portal', 'companies.owner_user_id', 'companies.created_at']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Company>
|
||||
*/
|
||||
private function companyOptionsWithSelected(User $user, ?int $selectedCompanyId, int $limit): Collection
|
||||
{
|
||||
$selectedCompany = $selectedCompanyId
|
||||
? $this->companyOptionQuery($user)->whereKey($selectedCompanyId)->first()
|
||||
: null;
|
||||
|
||||
$companies = $this->companyOptionQuery($user)
|
||||
->when($selectedCompany, fn (Builder $query): Builder => $query->whereKeyNot($selectedCompany->id))
|
||||
->latest('companies.created_at')
|
||||
->latest('companies.id')
|
||||
->limit($selectedCompany ? max(0, $limit - 1) : $limit)
|
||||
->get();
|
||||
|
||||
if (! $selectedCompany) {
|
||||
return $companies;
|
||||
}
|
||||
|
||||
return $companies
|
||||
->prepend($selectedCompany)
|
||||
->unique('id')
|
||||
->values();
|
||||
}
|
||||
|
||||
private function userCanAccessCompany(User $user, int $companyId): bool
|
||||
{
|
||||
return $this->accessibleCompanyQuery($user)
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ class ImageService
|
|||
'thumb' => ['width' => 320, 'height' => 240],
|
||||
'medium' => ['width' => 800, 'height' => 600],
|
||||
'large' => ['width' => 1600, 'height' => 1200],
|
||||
// Titelbild (Hero) der Detailansicht: harte Obergrenze 1280x580 px.
|
||||
'cover' => ['width' => 1280, 'height' => 580],
|
||||
];
|
||||
|
||||
public const ALLOWED_LOGO_MIME_TYPES = [
|
||||
|
|
@ -60,7 +62,7 @@ class ImageService
|
|||
|
||||
public const MAX_LOGO_BYTES = 4 * 1024 * 1024; // 4 MB
|
||||
|
||||
public const MAX_PRESS_RELEASE_IMAGE_BYTES = 8 * 1024 * 1024; // 8 MB
|
||||
public const MAX_PRESS_RELEASE_IMAGE_BYTES = 16 * 1024 * 1024; // 16 MB
|
||||
|
||||
public function __construct(private readonly string $disk = 'public') {}
|
||||
|
||||
|
|
@ -99,8 +101,9 @@ class ImageService
|
|||
}
|
||||
|
||||
/**
|
||||
* Persists a freshly uploaded press release image and generates all
|
||||
* variants. Original is stored under `press-releases/{id}/images`.
|
||||
* Persists a freshly uploaded press release image, generates all variants
|
||||
* and discards the original upload. The canonical stored path points to
|
||||
* the cover variant to keep storage usage predictable.
|
||||
*
|
||||
* @return array{
|
||||
* path: string,
|
||||
|
|
@ -122,9 +125,6 @@ class ImageService
|
|||
$disk = $this->disk();
|
||||
$disk->put($relativePath, $upload->get(), 'public');
|
||||
|
||||
$absolute = $disk->path($relativePath);
|
||||
$size = @getimagesize($absolute) ?: [null, null];
|
||||
|
||||
$variants = $this->generateVariants(
|
||||
$disk,
|
||||
$relativePath,
|
||||
|
|
@ -133,11 +133,19 @@ class ImageService
|
|||
cover: true,
|
||||
);
|
||||
|
||||
$coverPath = $variants['cover'] ?? $relativePath;
|
||||
$coverAbsolute = $disk->path($coverPath);
|
||||
$coverSize = @getimagesize($coverAbsolute) ?: [null, null];
|
||||
|
||||
if ($coverPath !== $relativePath && $disk->exists($relativePath)) {
|
||||
$disk->delete($relativePath);
|
||||
}
|
||||
|
||||
return [
|
||||
'path' => $relativePath,
|
||||
'path' => $coverPath,
|
||||
'variants' => $variants,
|
||||
'width' => is_int($size[0] ?? null) ? $size[0] : null,
|
||||
'height' => is_int($size[1] ?? null) ? $size[1] : null,
|
||||
'width' => is_int($coverSize[0] ?? null) ? $coverSize[0] : null,
|
||||
'height' => is_int($coverSize[1] ?? null) ? $coverSize[1] : null,
|
||||
'mime' => $upload->getMimeType(),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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}";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\PressRelease\ContentScore;
|
||||
|
||||
use App\Services\PressRelease\ContentScore\Contracts\ContentScoreDriver;
|
||||
use App\Services\PressRelease\ContentScore\Drivers\DeterministicContentScoreDriver;
|
||||
use App\Services\PressRelease\ContentScore\Drivers\OpenAiContentScoreDriver;
|
||||
use Illuminate\Support\Manager;
|
||||
|
||||
/**
|
||||
* Löst den aktiven Content-Score-Treiber anhand von
|
||||
* config('scoring.content_score.provider') auf (Laravel-Manager-Pattern).
|
||||
*/
|
||||
class ContentScoreManager extends Manager
|
||||
{
|
||||
public function getDefaultDriver(): string
|
||||
{
|
||||
return (string) ($this->config->get('scoring.content_score.provider') ?: 'deterministic');
|
||||
}
|
||||
|
||||
public function createDeterministicDriver(): ContentScoreDriver
|
||||
{
|
||||
return $this->container->make(DeterministicContentScoreDriver::class);
|
||||
}
|
||||
|
||||
public function createOpenaiDriver(): ContentScoreDriver
|
||||
{
|
||||
return $this->container->make(OpenAiContentScoreDriver::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\PressRelease\ContentScore;
|
||||
|
||||
/**
|
||||
* Ergebnis einer Content-Score-Berechnung (Qualität, Konzept §15.2).
|
||||
*
|
||||
* Provider-neutral: jeder Treiber liefert einen 0–100-Score plus optionalen
|
||||
* Faktor-Breakdown, damit der Job einheitlich persistieren/auditieren kann.
|
||||
*/
|
||||
final class ContentScoreResult
|
||||
{
|
||||
/**
|
||||
* @param int $score 0–100
|
||||
* @param array<string, mixed> $breakdown Faktor-Aufschlüsselung (optional)
|
||||
* @param array<string, mixed> $rawResponse Roh-Antwort für das Audit-Log
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly int $score,
|
||||
public readonly array $breakdown,
|
||||
public readonly string $provider,
|
||||
public readonly ?string $model,
|
||||
public readonly array $rawResponse,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\PressRelease\ContentScore\Contracts;
|
||||
|
||||
use App\Models\PressRelease;
|
||||
use App\Services\PressRelease\ContentScore\ContentScoreResult;
|
||||
|
||||
/**
|
||||
* Vertrag für austauschbare Content-Score-Treiber (OpenAI, später Anthropic/
|
||||
* Gemini, deterministischer Fallback). Auswahl über config/scoring.php.
|
||||
*/
|
||||
interface ContentScoreDriver
|
||||
{
|
||||
/**
|
||||
* Berechnet den Content-Score (0–100) einer Pressemitteilung.
|
||||
*
|
||||
* Wirft bei API-Fehlern/Timeouts eine Exception; der aufrufende Job weicht
|
||||
* dann auf den deterministischen Fallback aus.
|
||||
*/
|
||||
public function score(PressRelease $pressRelease): ContentScoreResult;
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\PressRelease\ContentScore\Drivers;
|
||||
|
||||
use App\Models\PressRelease;
|
||||
use App\Services\PressRelease\ContentScore\ContentScoreResult;
|
||||
use App\Services\PressRelease\ContentScore\Contracts\ContentScoreDriver;
|
||||
|
||||
/**
|
||||
* Regelbasierter Fallback ohne externe API.
|
||||
*
|
||||
* Greift, wenn kein KI-Anbieter konfiguriert ist oder die KI ausfällt. Bewertet
|
||||
* messbare, formale Faktoren (Textlänge, Bild, Quelle, Headline, Vollständigkeit)
|
||||
* zu einem groben 0–100-Score. Ersetzt keine inhaltliche KI-Bewertung, hält den
|
||||
* Funnel aber lauffähig.
|
||||
*/
|
||||
class DeterministicContentScoreDriver implements ContentScoreDriver
|
||||
{
|
||||
public function score(PressRelease $pressRelease): ContentScoreResult
|
||||
{
|
||||
$breakdown = [];
|
||||
$score = 40; // Basiswert für eine formal vorhandene PM
|
||||
$breakdown['base'] = 40;
|
||||
|
||||
$textLength = $pressRelease->plainTextLength();
|
||||
$lengthPoints = match (true) {
|
||||
$textLength >= 1500 => 20,
|
||||
$textLength >= 800 => 12,
|
||||
$textLength >= 300 => 6,
|
||||
default => 0,
|
||||
};
|
||||
$score += $lengthPoints;
|
||||
$breakdown['length'] = $lengthPoints;
|
||||
|
||||
$subtitlePoints = filled($pressRelease->subtitle) ? 6 : 0;
|
||||
$score += $subtitlePoints;
|
||||
$breakdown['subtitle'] = $subtitlePoints;
|
||||
|
||||
$imagePoints = $pressRelease->images()->count() > 0 ? 10 : 0;
|
||||
$score += $imagePoints;
|
||||
$breakdown['image'] = $imagePoints;
|
||||
|
||||
$sourcePoints = filled($pressRelease->backlink_url) ? 8 : 0;
|
||||
$score += $sourcePoints;
|
||||
$breakdown['source'] = $sourcePoints;
|
||||
|
||||
$titleLength = mb_strlen((string) $pressRelease->title);
|
||||
$headlinePoints = ($titleLength >= 30 && $titleLength <= 90) ? 8 : 0;
|
||||
$score += $headlinePoints;
|
||||
$breakdown['headline'] = $headlinePoints;
|
||||
|
||||
$keywordPoints = filled($pressRelease->keywords) ? 4 : 0;
|
||||
$score += $keywordPoints;
|
||||
$breakdown['keywords'] = $keywordPoints;
|
||||
|
||||
$contactPoints = $pressRelease->contacts()->count() > 0 ? 4 : 0;
|
||||
$score += $contactPoints;
|
||||
$breakdown['contact'] = $contactPoints;
|
||||
|
||||
$score = max(0, min(100, $score));
|
||||
|
||||
return new ContentScoreResult(
|
||||
score: $score,
|
||||
breakdown: $breakdown,
|
||||
provider: 'deterministic',
|
||||
model: null,
|
||||
rawResponse: ['breakdown' => $breakdown, 'text_length' => $textLength],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\PressRelease\ContentScore\Drivers;
|
||||
|
||||
use App\Models\PressRelease;
|
||||
use App\Services\PressRelease\ContentScore\ContentScoreResult;
|
||||
use App\Services\PressRelease\ContentScore\Contracts\ContentScoreDriver;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Content-Score-Treiber auf Basis der OpenAI Chat-Completions-API.
|
||||
*
|
||||
* Liest Zugangsdaten aus config/services.openai, Modell/Timeout aus
|
||||
* config/scoring.content_score. Verlangt strukturiertes JSON
|
||||
* `{score, breakdown}`. Bei fehlendem Key, HTTP-/Transportfehlern oder
|
||||
* ungültiger Antwort wird eine Exception geworfen (Job → Fallback).
|
||||
*/
|
||||
class OpenAiContentScoreDriver implements ContentScoreDriver
|
||||
{
|
||||
public function score(PressRelease $pressRelease): ContentScoreResult
|
||||
{
|
||||
$openai = config('services.openai');
|
||||
$apiKey = $openai['api_key'] ?? null;
|
||||
|
||||
if (blank($apiKey)) {
|
||||
throw new RuntimeException('OpenAI API-Key ist nicht konfiguriert.');
|
||||
}
|
||||
|
||||
$model = config('scoring.content_score.model') ?: ($openai['model'] ?? 'gpt-5.4-mini');
|
||||
$timeout = (int) (config('scoring.content_score.timeout') ?: 30);
|
||||
|
||||
$response = Http::withToken($apiKey)
|
||||
->timeout($timeout)
|
||||
->acceptJson()
|
||||
->post($openai['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-Content-Score 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['score']) || ! is_numeric($parsed['score'])) {
|
||||
throw new RuntimeException('OpenAI-Antwort war kein gültiges Score-JSON.');
|
||||
}
|
||||
|
||||
$score = (int) max(0, min(100, (int) round((float) $parsed['score'])));
|
||||
$breakdown = is_array($parsed['breakdown'] ?? null) ? $parsed['breakdown'] : [];
|
||||
|
||||
return new ContentScoreResult(
|
||||
score: $score,
|
||||
breakdown: $breakdown,
|
||||
provider: 'openai',
|
||||
model: $model,
|
||||
rawResponse: is_array($payload) ? $payload : [],
|
||||
);
|
||||
}
|
||||
|
||||
private function systemPrompt(): string
|
||||
{
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
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}}
|
||||
PROMPT;
|
||||
}
|
||||
|
||||
private function userPrompt(PressRelease $pressRelease): string
|
||||
{
|
||||
$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}";
|
||||
}
|
||||
}
|
||||
92
app/Services/PressRelease/PressReleaseCoverImage.php
Normal file
92
app/Services/PressRelease/PressReleaseCoverImage.php
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\PressRelease;
|
||||
|
||||
use App\Enums\PressReleasePlaceholder;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseImage;
|
||||
|
||||
/**
|
||||
* Auflösung des Titelbildes (Hero/Thumb) einer Pressemitteilung.
|
||||
*
|
||||
* Liefert immer ein darstellbares Bild: entweder das echte Vorschaubild
|
||||
* (bevorzugt das als `is_preview` markierte, sonst das erste) oder den
|
||||
* deterministischen SVG-Platzhalter aus dem Set.
|
||||
*/
|
||||
class PressReleaseCoverImage
|
||||
{
|
||||
/**
|
||||
* Liefert die URL zum Titelbild. Fällt auf den SVG-Platzhalter zurück.
|
||||
*
|
||||
* Die Variante wird über eine Fallback-Kette aufgelöst, damit auch
|
||||
* Altbestände ohne `cover`-Variante (1280x580) sauber rendern.
|
||||
*
|
||||
* @param string $variant Bevorzugte Bild-Variante (cover|large|medium|thumb).
|
||||
*/
|
||||
public function coverUrl(PressRelease $pressRelease, string $variant = 'cover'): string
|
||||
{
|
||||
$image = $this->coverImage($pressRelease);
|
||||
|
||||
if (! $image) {
|
||||
return $this->placeholderUrl($pressRelease);
|
||||
}
|
||||
|
||||
foreach ($this->fallbackChain($variant) as $key) {
|
||||
$url = $image->variantUrl($key);
|
||||
|
||||
if ($url !== null) {
|
||||
return $url;
|
||||
}
|
||||
}
|
||||
|
||||
return $image->url() ?? $this->placeholderUrl($pressRelease);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback-Reihenfolge der Bild-Varianten, beginnend bei der gewünschten.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function fallbackChain(string $preferred): array
|
||||
{
|
||||
$chain = [$preferred, 'cover', 'large', 'medium', 'thumb'];
|
||||
|
||||
return array_values(array_unique($chain));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ob für diese PM nur ein Platzhalter (kein echtes Bild) vorliegt.
|
||||
*/
|
||||
public function coverIsPlaceholder(PressRelease $pressRelease): bool
|
||||
{
|
||||
return $this->coverImage($pressRelease) === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Platzhalter-Variante dieser PM (mit Default-Fallback).
|
||||
*/
|
||||
public function placeholder(PressRelease $pressRelease): PressReleasePlaceholder
|
||||
{
|
||||
$variant = $pressRelease->placeholder_variant;
|
||||
|
||||
if ($variant instanceof PressReleasePlaceholder) {
|
||||
return $variant;
|
||||
}
|
||||
|
||||
return PressReleasePlaceholder::fromValueOrDefault($variant);
|
||||
}
|
||||
|
||||
private function placeholderUrl(PressRelease $pressRelease): string
|
||||
{
|
||||
return asset($this->placeholder($pressRelease)->path());
|
||||
}
|
||||
|
||||
private function coverImage(PressRelease $pressRelease): ?PressReleaseImage
|
||||
{
|
||||
$images = $pressRelease->relationLoaded('images')
|
||||
? $pressRelease->images
|
||||
: $pressRelease->images()->orderByDesc('is_preview')->orderBy('sort_order')->get();
|
||||
|
||||
return $images->firstWhere('is_preview', true) ?? $images->first();
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,10 @@
|
|||
|
||||
namespace App\Services\PressRelease;
|
||||
|
||||
use App\Enums\PressReleaseClassification;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Jobs\ClassifyPressRelease;
|
||||
use App\Jobs\ScorePressRelease;
|
||||
use App\Mail\PressReleasePublished;
|
||||
use App\Mail\PressReleaseRejected;
|
||||
use App\Models\AdminPreset;
|
||||
|
|
@ -43,9 +46,101 @@ class PressReleaseService
|
|||
|
||||
$pressRelease->update(['status' => PressReleaseStatus::Review->value]);
|
||||
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Review, null, 'customer');
|
||||
|
||||
// Quota-Stub: zählt den Monatsverbrauch des Autors hoch. Wird vom
|
||||
// echten Tarif-Modul später abgelöst (Schnittstelle bleibt stabil).
|
||||
$pressRelease->user?->increment('press_release_quota_used_this_month');
|
||||
|
||||
// KI-Klassifikation asynchron anstoßen (Red Flag). Das Routing anhand
|
||||
// des Ergebnisses übernimmt der Job über routeByClassification().
|
||||
ClassifyPressRelease::dispatch($pressRelease->id)->onQueue('classification');
|
||||
|
||||
// Content-Score parallel berechnen (Qualität, ohne Statuswirkung).
|
||||
ScorePressRelease::dispatch($pressRelease->id)->onQueue('classification');
|
||||
}
|
||||
|
||||
public function publish(PressRelease $pressRelease, string $source = 'admin'): void
|
||||
/**
|
||||
* Stößt eine erneute KI-Klassifikation an, wenn die PM bereits einmal
|
||||
* klassifiziert wurde (Konzept §15.1: „Bei Änderung wird neu klassifiziert").
|
||||
*
|
||||
* Läuft als Re-Check **ohne Routing**: Bewertung + Audit werden
|
||||
* aktualisiert, der Status bleibt unverändert. So führt das bloße
|
||||
* Bearbeiten nie zu einer überraschenden automatischen Veröffentlichung
|
||||
* oder Ablehnung – die Entscheidung bleibt beim regulären Workflow/Admin.
|
||||
*/
|
||||
public function reclassifyIfClassified(PressRelease $pressRelease): void
|
||||
{
|
||||
if ($pressRelease->classification === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
ClassifyPressRelease::dispatch($pressRelease->id, route: false)->onQueue('classification');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stößt eine erneute Content-Score-Berechnung an, wenn die PM bereits
|
||||
* einmal bewertet wurde (Konzept §15.2: „bei jeder Änderung neu berechnet").
|
||||
*/
|
||||
public function rescoreIfScored(PressRelease $pressRelease): void
|
||||
{
|
||||
if ($pressRelease->content_score === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
ScorePressRelease::dispatch($pressRelease->id)->onQueue('classification');
|
||||
}
|
||||
|
||||
/**
|
||||
* Routet eine frisch klassifizierte PM anhand des KI-Ergebnisses
|
||||
* (Konzept §15.1). Wird vom ClassifyPressRelease-Job aufgerufen.
|
||||
*
|
||||
* - Rot → Ablehnung mit Begründung an den Autor
|
||||
* - Gelb → bleibt in der manuellen Review-Queue
|
||||
* - Grün → automatische Veröffentlichung (sofort bzw. zum geplanten Termin)
|
||||
*
|
||||
* Greift nur, solange die PM noch im Status `review` steht; manuelle
|
||||
* Admin-Eingriffe in der Zwischenzeit haben damit Vorrang.
|
||||
*/
|
||||
public function routeByClassification(PressRelease $pressRelease, PressReleaseClassification $classification, ?string $reason = null): void
|
||||
{
|
||||
if ($pressRelease->status !== PressReleaseStatus::Review) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($classification === PressReleaseClassification::Red) {
|
||||
$this->reject($pressRelease, $reason ?: 'Automatische Ablehnung durch die KI-Prüfung.', 'ki');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($classification === PressReleaseClassification::Green) {
|
||||
$this->autoPublishGreen($pressRelease);
|
||||
}
|
||||
|
||||
// Gelb: keine Aktion – bleibt zur manuellen Prüfung im Status „review".
|
||||
}
|
||||
|
||||
/**
|
||||
* Veröffentlicht eine grün klassifizierte PM automatisch.
|
||||
*
|
||||
* Liegt ein Veröffentlichungstermin in der Zukunft, übernimmt der
|
||||
* Scheduler die Publikation zum Termin. Andernfalls wird sofort
|
||||
* publiziert – optional mit einem Sicherheitsfenster
|
||||
* (scoring.classification.green_delay_minutes).
|
||||
*/
|
||||
private function autoPublishGreen(PressRelease $pressRelease): void
|
||||
{
|
||||
if ($pressRelease->scheduled_at && $pressRelease->scheduled_at->isFuture()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$delayMinutes = (int) config('scoring.classification.green_delay_minutes', 0);
|
||||
$publishedAtOverride = $delayMinutes > 0 ? now()->addMinutes($delayMinutes) : null;
|
||||
|
||||
$this->publish($pressRelease, 'ki', $publishedAtOverride);
|
||||
}
|
||||
|
||||
public function publish(PressRelease $pressRelease, string $source = 'admin', ?Carbon $publishedAtOverride = null): void
|
||||
{
|
||||
$this->assertStatus($pressRelease, [PressReleaseStatus::Review]);
|
||||
|
||||
|
|
@ -63,7 +158,7 @@ class PressReleaseService
|
|||
|
||||
$pressRelease->update([
|
||||
'status' => PressReleaseStatus::Published->value,
|
||||
'published_at' => $this->resolvePublishedAt($pressRelease),
|
||||
'published_at' => $this->resolvePublishedAt($pressRelease, $publishedAtOverride),
|
||||
]);
|
||||
|
||||
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Published, null, $source);
|
||||
|
|
@ -83,14 +178,18 @@ class PressReleaseService
|
|||
* Damit wirken sowohl Scheduling als auch Embargo automatisch über den
|
||||
* vorhandenen Sichtbarkeits-Filter `where(published_at <= now())` im
|
||||
* öffentlichen Listing.
|
||||
*
|
||||
* `$override` setzt einen abweichenden Sofort-Zeitpunkt (z.B. das
|
||||
* Grün-Sicherheitsfenster) und wirkt nur, wenn kein `scheduled_at` gesetzt
|
||||
* ist – ein geplanter Termin hat stets Vorrang.
|
||||
*/
|
||||
private function resolvePublishedAt(PressRelease $pressRelease): Carbon
|
||||
private function resolvePublishedAt(PressRelease $pressRelease, ?Carbon $override = null): Carbon
|
||||
{
|
||||
if ($pressRelease->published_at) {
|
||||
return $pressRelease->published_at;
|
||||
}
|
||||
|
||||
$base = $pressRelease->scheduled_at ?: now();
|
||||
$base = $pressRelease->scheduled_at ?: ($override ?? now());
|
||||
|
||||
if ($pressRelease->embargo_at && $pressRelease->embargo_at->greaterThan($base)) {
|
||||
return $pressRelease->embargo_at;
|
||||
|
|
@ -99,7 +198,7 @@ class PressReleaseService
|
|||
return $base;
|
||||
}
|
||||
|
||||
public function reject(PressRelease $pressRelease, ?string $reason = null): void
|
||||
public function reject(PressRelease $pressRelease, ?string $reason = null, string $source = 'admin'): void
|
||||
{
|
||||
$this->assertStatus($pressRelease, [PressReleaseStatus::Review]);
|
||||
|
||||
|
|
@ -107,7 +206,7 @@ class PressReleaseService
|
|||
|
||||
$pressRelease->update(['status' => PressReleaseStatus::Rejected->value]);
|
||||
|
||||
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Rejected, $reason, 'admin');
|
||||
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Rejected, $reason, $source);
|
||||
$this->notifyAuthor($pressRelease, 'rejected', $reason);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue