From 3ae57d9dba938016e1a32f2bc4345999cb243ec4 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Fri, 10 Jul 2026 16:37:47 +0000 Subject: [PATCH] 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 --- app/Jobs/ScorePressRelease.php | 2 +- .../Drivers/OpenAiClassificationDriver.php | 88 ++++- .../Drivers/OpenAiContentScoreDriver.php | 44 ++- .../admin/press-releases/index.blade.php | 364 +++++++++++++++--- .../Admin/AdminPressReleaseActionsTest.php | 140 +++++++ .../PressReleaseClassificationJobTest.php | 28 ++ .../Feature/PressReleaseContentScoreTest.php | 36 ++ 7 files changed, 613 insertions(+), 89 deletions(-) diff --git a/app/Jobs/ScorePressRelease.php b/app/Jobs/ScorePressRelease.php index c2d97acd..4362cce8 100644 --- a/app/Jobs/ScorePressRelease.php +++ b/app/Jobs/ScorePressRelease.php @@ -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(), ]); } diff --git a/app/Services/PressRelease/Classification/Drivers/OpenAiClassificationDriver.php b/app/Services/PressRelease/Classification/Drivers/OpenAiClassificationDriver.php index fb7967fe..a63b3bba 100644 --- a/app/Services/PressRelease/Classification/Drivers/OpenAiClassificationDriver.php +++ b/app/Services/PressRelease/Classification/Drivers/OpenAiClassificationDriver.php @@ -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 <<: 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; diff --git a/app/Services/PressRelease/ContentScore/Drivers/OpenAiContentScoreDriver.php b/app/Services/PressRelease/ContentScore/Drivers/OpenAiContentScoreDriver.php index 2f38c5f3..c4efa01a 100644 --- a/app/Services/PressRelease/ContentScore/Drivers/OpenAiContentScoreDriver.php +++ b/app/Services/PressRelease/ContentScore/Drivers/OpenAiContentScoreDriver.php @@ -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}"; } } diff --git a/resources/views/livewire/admin/press-releases/index.blade.php b/resources/views/livewire/admin/press-releases/index.blade.php index f9c96a76..1ef7be8f 100644 --- a/resources/views/livewire/admin/press-releases/index.blade.php +++ b/resources/views/livewire/admin/press-releases/index.blade.php @@ -9,6 +9,7 @@ use App\Enums\PressReleaseStatus; use App\Models\Category; use App\Models\Company; use App\Models\Contact; +use App\Models\KiAudit; use App\Models\PressRelease; use App\Models\User; use App\Services\Admin\AdminPerformanceCache; @@ -164,17 +165,41 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten ); } + /** PM-ID, deren KI-Prüfdetails aktuell im Modal angezeigt werden. */ + public ?int $aiCheckId = null; + + /** Zeitpunkt (ISO 8601) des zuletzt angestoßenen Laufs — steuert die „läuft…"-Anzeige. */ + public ?string $aiCheckStartedAt = null; + + public function openAiCheck(int $id): void + { + $this->aiCheckId = $id; + $this->aiCheckStartedAt = null; + + Flux::modal('ai-check-details')->show(); + } + /** * Manuelle KI-Prüfung aus der Liste — bewusst ohne Routing: bewertet * und protokolliert, ändert aber nie den Status (Admin entscheidet). */ - public function runAiCheck(int $id): void + public function runAiCheck(?int $id = null): void { + $id ??= $this->aiCheckId; + + if ($id === null) { + return; + } + $pr = $this->findAdminPR($id); ClassifyPressRelease::dispatch($pr->id, route: false)->onQueue('classification'); ScorePressRelease::dispatch($pr->id)->onQueue('classification'); + if ($id === $this->aiCheckId) { + $this->aiCheckStartedAt = now()->toIso8601String(); + } + Flux::toast(__('KI-Prüfung angestoßen — Ergebnis erscheint nach dem nächsten Queue-Lauf.'), variant: 'success'); } @@ -278,6 +303,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten return [ 'pressReleases' => $query, + 'aiCheck' => $this->aiCheckDetails(), 'stats' => $this->pressReleaseStats(), 'statusOptions' => PressReleaseStatus::cases(), 'portalOptions' => Portal::cases(), @@ -288,6 +314,74 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten ]; } + /** + * Daten für das KI-Prüfungs-Modal: PM, jüngste Audit-Einträge und ob + * gerade ein angestoßener Lauf noch aussteht (Queue noch nicht durch). + * + * @return array{pr: PressRelease, classificationAudit: KiAudit|null, scoreAudit: KiAudit|null, pending: bool}|null + */ + private function aiCheckDetails(): ?array + { + if ($this->aiCheckId === null) { + return null; + } + + $pr = PressRelease::withoutGlobalScopes() + ->with(['kiAudits' => fn ($q) => $q->limit(10)]) + ->find($this->aiCheckId); + + if ($pr === null) { + return null; + } + + $classificationAudit = $pr->kiAudits->firstWhere('type', KiAudit::TYPE_CLASSIFICATION); + $scoreAudit = $pr->kiAudits->firstWhere('type', KiAudit::TYPE_CONTENT_SCORE); + + $startedAt = $this->aiCheckStartedAt !== null + ? \Illuminate\Support\Carbon::parse($this->aiCheckStartedAt) + : null; + + $pending = $startedAt !== null && ( + $classificationAudit === null + || $classificationAudit->created_at->lt($startedAt) + || $scoreAudit === null + || $scoreAudit->created_at->lt($startedAt) + ); + + return [ + 'pr' => $pr, + 'classificationAudit' => $classificationAudit, + 'scoreAudit' => $scoreAudit, + 'scoreBreakdown' => $this->scoreBreakdown($scoreAudit), + 'pending' => $pending, + ]; + } + + /** + * Extrahiert die Faktor-Aufschlüsselung des Content-Scores aus der + * Audit-Roh-Antwort. Deterministischer Treiber legt sie direkt ab, + * OpenAI liefert sie im JSON der Message. + * + * @return array + */ + private function scoreBreakdown(?KiAudit $scoreAudit): array + { + if ($scoreAudit === null) { + return []; + } + + $raw = $scoreAudit->raw_response ?? []; + $breakdown = $raw['breakdown'] ?? null; + + if (! is_array($breakdown)) { + $content = data_get($raw, 'choices.0.message.content'); + $parsed = is_string($content) ? json_decode($content, true) : null; + $breakdown = is_array($parsed['breakdown'] ?? null) ? $parsed['breakdown'] : []; + } + + return array_filter($breakdown, static fn ($value): bool => is_numeric($value)); + } + /** * @return array{total: int, published: int, review: int, draft: int, rejected: int, archived: int} */ @@ -846,6 +940,10 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten + {{ __('Aktionen') }} + {{ __('Datum') }} + {{ __('KI-Prüfung') }} {{ __('Status') }} {{ __('Portal') }} {{ __('Kategorie') }} - {{ __('Datum') }} {{ __('Hits') }} - {{ __('KI-Prüfung') }} - {{ __('Aktionen') }} @forelse($pressReleases as $pr) @@ -886,6 +980,59 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten : $pr->created_at; @endphp + +
+ + +
+
+ + +
+ {{ $primaryDate?->format('d.m.Y') }} +
+
+ {{ $dateSubLabel }} · {{ $primaryDate?->format('H:i') }} +
+ @if ($status === 'review' && $pr->scheduled_at && $pr->scheduled_at->isFuture()) +
+ + {{ __('geplant') }} · {{ $pr->scheduledAtLocal()->format('d.m. H:i') }} +
+ @endif + @if ($pr->embargo_at && $pr->embargo_at->isFuture()) +
+ + {{ __('Embargo bis') }} {{ $pr->embargoAtLocal()->format('d.m.') }} +
+ @endif +
+ + +
+ + @if ($pr->classification) + @php + $kiBadgeClass = match ($pr->classification) { + \App\Enums\PressReleaseClassification::Green => 'published', + \App\Enums\PressReleaseClassification::Yellow => 'review', + \App\Enums\PressReleaseClassification::Red => 'rejected', + }; + @endphp + + @endif +
+
+
{{ $pr->status->label() }} @@ -968,59 +1115,9 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten - -
- {{ $primaryDate?->format('d.m.Y') }} -
-
- {{ $dateSubLabel }} · {{ $primaryDate?->format('H:i') }} -
- @if ($status === 'review' && $pr->scheduled_at && $pr->scheduled_at->isFuture()) -
- - {{ __('geplant') }} · {{ $pr->scheduledAtLocal()->format('d.m. H:i') }} -
- @endif - @if ($pr->embargo_at && $pr->embargo_at->isFuture()) -
- - {{ __('Embargo bis') }} {{ $pr->embargoAtLocal()->format('d.m.') }} -
- @endif -
- {{ number_format($pr->hits) }} - - - - @if ($pr->classification) - @php - $kiBadgeClass = match ($pr->classification) { - \App\Enums\PressReleaseClassification::Green => 'published', - \App\Enums\PressReleaseClassification::Yellow => 'review', - \App\Enums\PressReleaseClassification::Red => 'rejected', - }; - @endphp - {{ $pr->classification->label() }} - @else - - {{ __('Prüfen') }} - - @endif - - - -
- - -
@if ($pr->status === \App\Enums\PressReleaseStatus::Review) @@ -1085,7 +1182,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten @empty - + @if ($hasAnyFilter) {{ $pressReleases->links('components.portal.pagination') }} + + {{-- ============== KI-PRÜFUNGS-MODAL ============== --}} + + @if ($aiCheck) + @php + $aiPr = $aiCheck['pr']; + $classificationAudit = $aiCheck['classificationAudit']; + $scoreAudit = $aiCheck['scoreAudit']; + @endphp +
+
+ {{ __('KI-Prüfung') }} + + PM-{{ $aiPr->id }} · {{ \Illuminate\Support\Str::limit($aiPr->title, 90) }} + +
+ + @if ($aiCheck['pending']) +
+ + {{ __('Prüfung läuft — das Ergebnis erscheint hier automatisch, sobald die Queue durchgelaufen ist.') }} +
+ @endif + +
+ {{-- Klassifikation (Rot/Gelb/Grün) --}} +
+ {{ __('Klassifikation') }} +
+ @if ($aiPr->classification) + @php + $kiBadge = match ($aiPr->classification) { + \App\Enums\PressReleaseClassification::Green => 'ok', + \App\Enums\PressReleaseClassification::Yellow => 'warn', + \App\Enums\PressReleaseClassification::Red => 'err', + }; + @endphp + {{ $aiPr->classification->label() }} + @else + {{ __('Noch nicht geprüft') }} + @endif +
+ @if ($classificationAudit) + @if ($classificationAudit->reason) +

+ {{ $classificationAudit->reason }} +

+ @elseif ($aiPr->classification === \App\Enums\PressReleaseClassification::Green) +

+ {{ __('Keine Auffälligkeiten — bei Grün liefert die KI keine Begründung.') }} +

+ @endif +

+ {{ $classificationAudit->created_at->format('d.m.Y H:i') }} + · {{ $classificationAudit->provider }} + @if ($classificationAudit->model) + · {{ $classificationAudit->model }} + @endif +

+ @endif +
+ + {{-- Content-Score --}} +
+ {{ __('Content-Score') }} +
+ @if (! is_null($aiPr->content_score) && $aiPr->content_tier) + @php + $tierBadge = match ($aiPr->content_tier) { + \App\Enums\PressReleaseContentTier::Hochwertig => 'ok', + \App\Enums\PressReleaseContentTier::Geprueft => 'hub', + \App\Enums\PressReleaseContentTier::Standard => 'muted', + }; + @endphp + {{ $aiPr->content_score }} / 100 · {{ $aiPr->content_tier->label() }} + @else + {{ __('Noch nicht bewertet') }} + @endif +
+ @if ($aiCheck['scoreBreakdown'] !== []) + @php + $breakdownMeta = $scoreAudit?->provider === 'deterministic' + ? [ + 'base' => [__('Basiswert'), 40], + 'length' => [__('Textlänge'), 20], + 'subtitle' => [__('Untertitel'), 6], + 'image' => [__('Bildmaterial'), 10], + 'source' => [__('Quelle/Link'), 8], + 'headline' => [__('Headline'), 8], + 'keywords' => [__('Keywords'), 4], + 'contact' => [__('Pressekontakt'), 4], + ] + : [ + 'pressestil' => [__('Pressestil'), 25], + 'struktur' => [__('Struktur'), 15], + 'lesbarkeit' => [__('Lesbarkeit'), 10], + 'vollstaendigkeit' => [__('Vollständigkeit'), 15], + 'bild' => [__('Bildmaterial'), 10], + 'quellen' => [__('Quellen/Belege'), 5], + 'headline' => [__('Headline'), 10], + 'originalitaet' => [__('Originalität'), 10], + ]; + @endphp +
+ @foreach ($aiCheck['scoreBreakdown'] as $factor => $points) + @php + [$factorLabel, $factorMax] = $breakdownMeta[$factor] ?? [ucfirst($factor), null]; + $factorRatio = $factorMax ? min(100, (int) round(($points / $factorMax) * 100)) : null; + @endphp +
+
+ {{ $factorLabel }} +
+
+ @if ($factorRatio !== null) +
+
+
+ @endif +
+
+ {{ $points }}{{ $factorMax !== null ? ' / '.$factorMax : '' }} +
+
+ @endforeach +
+ @endif + @if ($scoreAudit) +

+ {{ $scoreAudit->created_at->format('d.m.Y H:i') }} + · {{ $scoreAudit->provider }} + @if ($scoreAudit->model) + · {{ $scoreAudit->model }} + @endif +

+ @endif +
+
+ +

+ {{ __('Die manuelle Prüfung bewertet und protokolliert nur — der Status der PM bleibt unverändert.') }} +

+ +
+ + {{ __('Schließen') }} + + + {{ $aiPr->classification ? __('Erneut prüfen') : __('Prüfung starten') }} + +
+
+ @endif +
diff --git a/tests/Feature/Admin/AdminPressReleaseActionsTest.php b/tests/Feature/Admin/AdminPressReleaseActionsTest.php index 0ba605b0..5c44a0ca 100644 --- a/tests/Feature/Admin/AdminPressReleaseActionsTest.php +++ b/tests/Feature/Admin/AdminPressReleaseActionsTest.php @@ -1,10 +1,16 @@ assertSee('Pressemitteilung archivieren?'); }); +test('ai check modal shows classification details from the latest audit', function () { + /** @var TestCase $this */ + $this->seed(RolesAndPermissionsSeeder::class); + + $admin = User::factory()->create(['is_active' => true]); + $admin->assignRole('admin'); + $this->actingAs($admin); + + $pressRelease = PressRelease::factory() + ->inReview() + ->create(['classification' => PressReleaseClassification::Yellow->value]); + + KiAudit::factory() + ->classification(PressReleaseClassification::Yellow) + ->create([ + 'press_release_id' => $pressRelease->id, + 'reason' => 'Werbliche Sprache im Titel gefunden.', + 'provider' => 'anthropic', + ]); + + LivewireVolt::test('admin.press-releases.index') + ->call('openAiCheck', $pressRelease->id) + ->assertSet('aiCheckId', $pressRelease->id) + ->assertSee('Werbliche Sprache im Titel gefunden.') + ->assertSee('anthropic') + ->assertSee('Erneut prüfen'); +}); + +test('ai check modal shows empty state and start button for unchecked press releases', function () { + /** @var TestCase $this */ + $this->seed(RolesAndPermissionsSeeder::class); + + $admin = User::factory()->create(['is_active' => true]); + $admin->assignRole('admin'); + $this->actingAs($admin); + + $pressRelease = PressRelease::factory() + ->inReview() + ->create(['classification' => null]); + + LivewireVolt::test('admin.press-releases.index') + ->call('openAiCheck', $pressRelease->id) + ->assertSee('Noch nicht geprüft') + ->assertSee('Noch nicht bewertet') + ->assertSee('Prüfung starten'); +}); + +test('ai check modal shows the content score breakdown from the audit raw response', function () { + /** @var TestCase $this */ + $this->seed(RolesAndPermissionsSeeder::class); + + $admin = User::factory()->create(['is_active' => true]); + $admin->assignRole('admin'); + $this->actingAs($admin); + + $pressRelease = PressRelease::factory() + ->inReview() + ->create([ + 'classification' => PressReleaseClassification::Green->value, + 'content_score' => 62, + 'content_tier' => PressReleaseContentTier::Geprueft->value, + ]); + + KiAudit::factory() + ->contentScore(62) + ->create([ + 'press_release_id' => $pressRelease->id, + 'provider' => 'openai', + 'raw_response' => [ + 'breakdown' => [ + 'pressestil' => 12, + 'struktur' => 8, + 'bild' => 0, + ], + ], + ]); + + LivewireVolt::test('admin.press-releases.index') + ->call('openAiCheck', $pressRelease->id) + ->assertSee('Pressestil') + ->assertSee('12 / 25') + ->assertSee('Struktur') + ->assertSee('8 / 15') + ->assertSee('Bildmaterial') + ->assertSee('0 / 10'); +}); + +test('ai check modal explains missing reason for green classifications', function () { + /** @var TestCase $this */ + $this->seed(RolesAndPermissionsSeeder::class); + + $admin = User::factory()->create(['is_active' => true]); + $admin->assignRole('admin'); + $this->actingAs($admin); + + $pressRelease = PressRelease::factory() + ->inReview() + ->create(['classification' => PressReleaseClassification::Green->value]); + + KiAudit::factory() + ->classification(PressReleaseClassification::Green) + ->create([ + 'press_release_id' => $pressRelease->id, + 'reason' => null, + ]); + + LivewireVolt::test('admin.press-releases.index') + ->call('openAiCheck', $pressRelease->id) + ->assertSee('bei Grün liefert die KI keine Begründung'); +}); + +test('runAiCheck from modal dispatches jobs without routing and marks the run as pending', function () { + /** @var TestCase $this */ + $this->seed(RolesAndPermissionsSeeder::class); + Queue::fake(); + + $admin = User::factory()->create(['is_active' => true]); + $admin->assignRole('admin'); + $this->actingAs($admin); + + $pressRelease = PressRelease::factory() + ->inReview() + ->create(['classification' => null]); + + LivewireVolt::test('admin.press-releases.index') + ->call('openAiCheck', $pressRelease->id) + ->assertSet('aiCheckStartedAt', null) + ->call('runAiCheck') + ->assertSee('Prüfung läuft'); + + Queue::assertPushed(ClassifyPressRelease::class, fn (ClassifyPressRelease $job): bool => $job->pressReleaseId === $pressRelease->id && $job->route === false); + Queue::assertPushed(ScorePressRelease::class, fn (ScorePressRelease $job): bool => $job->pressReleaseId === $pressRelease->id); +}); + test('show page renders confirmation modals for status actions', function () { /** @var TestCase $this */ $this->seed(RolesAndPermissionsSeeder::class); diff --git a/tests/Feature/PressReleaseClassificationJobTest.php b/tests/Feature/PressReleaseClassificationJobTest.php index fd754e43..0d10a3e4 100644 --- a/tests/Feature/PressReleaseClassificationJobTest.php +++ b/tests/Feature/PressReleaseClassificationJobTest.php @@ -7,12 +7,16 @@ use App\Mail\PressReleasePublished; use App\Mail\PressReleaseRejected; use App\Models\KiAudit; use App\Models\PressRelease; +use App\Models\RejectionCategory; use App\Models\User; use App\Services\PressRelease\Classification\ClassificationManager; use App\Services\PressRelease\PressReleaseService; +use Database\Seeders\RejectionCategorySeeder; +use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Queue; +use Tests\TestCase; function fakeOpenAiClassification(string $classification, array $reasons = []): void { @@ -66,6 +70,30 @@ test('classify job records a yellow classification with reasons', function () { expect($audit->reason)->toContain('Werbesprache'); }); +test('openai classification prompt lists the active rejection categories', function () { + /** @var TestCase $this */ + $this->seed(RejectionCategorySeeder::class); + RejectionCategory::query()->where('code', 'K6')->update(['active' => false]); + + fakeOpenAiClassification('green'); + + $pressRelease = PressRelease::factory()->create(['title' => 'Sauber', 'text' => 'Inhalt']); + + (new ClassifyPressRelease($pressRelease->id))->handle( + app(ClassificationManager::class), + app(PressReleaseService::class), + ); + + Http::assertSent(function (Request $request): bool { + $systemPrompt = (string) data_get($request->data(), 'messages.0.content'); + + return str_contains($systemPrompt, 'K1 Rechtswidrige Inhalte') + && str_contains($systemPrompt, 'K5 Rechte Dritter') + && ! str_contains($systemPrompt, 'K6 Kein Pressecharakter') + && str_contains($systemPrompt, 'markiere die Mitteilung dann direkt rot'); + }); +}); + test('classify job falls back to the deterministic driver when openai fails', function () { config()->set('scoring.classification.provider', 'openai'); config()->set('services.openai.api_key', 'test-key'); diff --git a/tests/Feature/PressReleaseContentScoreTest.php b/tests/Feature/PressReleaseContentScoreTest.php index 040ed0fb..317d835d 100644 --- a/tests/Feature/PressReleaseContentScoreTest.php +++ b/tests/Feature/PressReleaseContentScoreTest.php @@ -62,6 +62,42 @@ test('score job stores the openai score, derives the tier and writes an audit', expect($audit->result)->toBe('72'); }); +test('openai score awards the full image points when an upload image exists', function () { + fakeOpenAiScore(62); + + $pressRelease = PressRelease::factory()->create(); + $pressRelease->images()->create([ + 'disk' => 'public', + 'path' => 'press-releases/'.$pressRelease->id.'/images/title.jpg', + 'is_preview' => true, + 'sort_order' => 1, + ]); + + (new ScorePressRelease($pressRelease->id))->handle(app(ContentScoreManager::class)); + + expect($pressRelease->fresh()->content_score)->toBe(72); + + $audit = KiAudit::where('press_release_id', $pressRelease->id) + ->where('type', KiAudit::TYPE_CONTENT_SCORE) + ->firstOrFail(); + expect($audit->raw_response['breakdown']['bild'])->toBe(10); +}); + +test('openai score without image stays uncapped at the ai maximum of 90', function () { + fakeOpenAiScore(100); + + $pressRelease = PressRelease::factory()->create(); + + (new ScorePressRelease($pressRelease->id))->handle(app(ContentScoreManager::class)); + + expect($pressRelease->fresh()->content_score)->toBe(90); + + $audit = KiAudit::where('press_release_id', $pressRelease->id) + ->where('type', KiAudit::TYPE_CONTENT_SCORE) + ->firstOrFail(); + expect($audit->raw_response['breakdown']['bild'])->toBe(0); +}); + test('score job falls back to the deterministic driver when openai fails', function () { config()->set('scoring.content_score.provider', 'openai'); config()->set('services.openai.api_key', 'test-key');