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:
Kevin Adametz 2026-07-10 16:37:47 +00:00
parent 9cb53b1e17
commit b26dc9057c
7 changed files with 613 additions and 89 deletions

View file

@ -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<string, int|float>
*/
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
</div>
<flux:table>
<flux:table.columns>
<flux:table.column class="w-[90px]">{{ __('Aktionen') }}</flux:table.column>
<flux:table.column class="w-[130px]" sortable :sorted="$sortBy === 'created_at'"
:direction="$sortDir" wire:click="sort('created_at')">{{ __('Datum') }}</flux:table.column>
<flux:table.column class="w-[120px]">{{ __('KI-Prüfung') }}</flux:table.column>
<flux:table.column class="w-[200px]" sortable :sorted="$sortBy === 'status'" :direction="$sortDir"
wire:click="sort('status')">{{ __('Status') }}</flux:table.column>
<flux:table.column sortable :sorted="$sortBy === 'title'" :direction="$sortDir"
@ -853,12 +951,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
<flux:table.column class="w-[200px]" sortable :sorted="$sortBy === 'portal'"
:direction="$sortDir" wire:click="sort('portal')">{{ __('Portal') }}</flux:table.column>
<flux:table.column class="w-[180px]">{{ __('Kategorie') }}</flux:table.column>
<flux:table.column class="w-[130px]" sortable :sorted="$sortBy === 'created_at'"
:direction="$sortDir" wire:click="sort('created_at')">{{ __('Datum') }}</flux:table.column>
<flux:table.column class="w-[80px]" sortable :sorted="$sortBy === 'hits'" :direction="$sortDir"
wire:click="sort('hits')">{{ __('Hits') }}</flux:table.column>
<flux:table.column class="w-[120px]">{{ __('KI-Prüfung') }}</flux:table.column>
<flux:table.column class="w-[90px]">{{ __('Aktionen') }}</flux:table.column>
</flux:table.columns>
@forelse($pressReleases as $pr)
@ -886,6 +980,59 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
: $pr->created_at;
@endphp
<flux:table.row wire:key="{{ $pr->id }}" class="{{ $rowClass }}">
<flux:table.cell>
<div class="flex items-center gap-1">
<flux:button size="sm" variant="filled" icon="eye"
href="{{ route('admin.press-releases.show', $pr->id) }}" wire:navigate
title="{{ __('Ansehen') }}" />
<flux:button size="sm" variant="filled" icon="pencil"
href="{{ route('admin.press-releases.edit', $pr->id) }}" wire:navigate
title="{{ __('Bearbeiten') }}" />
</div>
</flux:table.cell>
<flux:table.cell>
<div class="font-mono text-[12px] text-[color:var(--color-ink-2)] tabular-nums">
{{ $primaryDate?->format('d.m.Y') }}
</div>
<div class="text-[10.5px] text-[color:var(--color-ink-4)] mt-0.5">
{{ $dateSubLabel }} · {{ $primaryDate?->format('H:i') }}
</div>
@if ($status === 'review' && $pr->scheduled_at && $pr->scheduled_at->isFuture())
<div class="mt-1 inline-flex items-center gap-1 text-[10.5px] font-mono tabular-nums text-[color:var(--color-accent-deep)]">
<flux:icon.calendar variant="micro" class="size-3" />
<span>{{ __('geplant') }} · {{ $pr->scheduledAtLocal()->format('d.m. H:i') }}</span>
</div>
@endif
@if ($pr->embargo_at && $pr->embargo_at->isFuture())
<div class="mt-1 inline-flex items-center gap-1 text-[10.5px] font-mono tabular-nums text-[color:var(--color-accent-deep)]">
<flux:icon.lock-closed variant="micro" class="size-3" />
<span>{{ __('Embargo bis') }} {{ $pr->embargoAtLocal()->format('d.m.') }}</span>
</div>
@endif
</flux:table.cell>
<flux:table.cell>
<div class="flex items-center gap-1.5">
<flux:button size="sm" variant="filled" icon="sparkles"
wire:click="openAiCheck({{ $pr->id }})"
title="{{ __('KI-Prüfung öffnen') }}" />
@if ($pr->classification)
@php
$kiBadgeClass = match ($pr->classification) {
\App\Enums\PressReleaseClassification::Green => 'published',
\App\Enums\PressReleaseClassification::Yellow => 'review',
\App\Enums\PressReleaseClassification::Red => 'rejected',
};
@endphp
<button type="button" wire:click="openAiCheck({{ $pr->id }})"
class="cursor-pointer" title="{{ __('KI-Prüfung öffnen') }}">
<span @class(['badge', $kiBadgeClass])>{{ $pr->classification->label() }}</span>
</button>
@endif
</div>
</flux:table.cell>
<flux:table.cell>
<div class="flex items-center gap-1.5 flex-wrap">
<span class="badge {{ $badgeClass }} dot">{{ $pr->status->label() }}</span>
@ -968,59 +1115,9 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
</span>
</flux:table.cell>
<flux:table.cell>
<div class="font-mono text-[12px] text-[color:var(--color-ink-2)] tabular-nums">
{{ $primaryDate?->format('d.m.Y') }}
</div>
<div class="text-[10.5px] text-[color:var(--color-ink-4)] mt-0.5">
{{ $dateSubLabel }} · {{ $primaryDate?->format('H:i') }}
</div>
@if ($status === 'review' && $pr->scheduled_at && $pr->scheduled_at->isFuture())
<div class="mt-1 inline-flex items-center gap-1 text-[10.5px] font-mono tabular-nums text-[color:var(--color-accent-deep)]">
<flux:icon.calendar variant="micro" class="size-3" />
<span>{{ __('geplant') }} · {{ $pr->scheduledAtLocal()->format('d.m. H:i') }}</span>
</div>
@endif
@if ($pr->embargo_at && $pr->embargo_at->isFuture())
<div class="mt-1 inline-flex items-center gap-1 text-[10.5px] font-mono tabular-nums text-[color:var(--color-accent-deep)]">
<flux:icon.lock-closed variant="micro" class="size-3" />
<span>{{ __('Embargo bis') }} {{ $pr->embargoAtLocal()->format('d.m.') }}</span>
</div>
@endif
</flux:table.cell>
<flux:table.cell>
<span
class="font-mono text-[12px] text-[color:var(--color-ink-2)] tabular-nums">{{ number_format($pr->hits) }}</span>
</flux:table.cell>
<flux:table.cell>
@if ($pr->classification)
@php
$kiBadgeClass = match ($pr->classification) {
\App\Enums\PressReleaseClassification::Green => 'published',
\App\Enums\PressReleaseClassification::Yellow => 'review',
\App\Enums\PressReleaseClassification::Red => 'rejected',
};
@endphp
<span @class(['badge', $kiBadgeClass])>{{ $pr->classification->label() }}</span>
@else
<flux:button size="sm" variant="filled" icon="sparkles" wire:click="runAiCheck({{ $pr->id }})"
wire:loading.attr="disabled" title="{{ __('KI-Prüfung starten (ohne Statusänderung)') }}">
{{ __('Prüfen') }}
</flux:button>
@endif
</flux:table.cell>
<flux:table.cell>
<div class="flex items-center gap-1">
<flux:button size="sm" variant="filled" icon="eye"
href="{{ route('admin.press-releases.show', $pr->id) }}" wire:navigate
title="{{ __('Ansehen') }}" />
<flux:button size="sm" variant="filled" icon="pencil"
href="{{ route('admin.press-releases.edit', $pr->id) }}" wire:navigate
title="{{ __('Bearbeiten') }}" />
</div>
@if ($pr->status === \App\Enums\PressReleaseStatus::Review)
<flux:modal name="confirm-index-publish-{{ $pr->id }}" class="max-w-lg">
@ -1085,7 +1182,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
</flux:table.row>
@empty
<flux:table.row>
<flux:table.cell colspan="7">
<flux:table.cell colspan="8">
@if ($hasAnyFilter)
<x-portal.empty-state
variant="warm"
@ -1127,4 +1224,159 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
</article>
{{ $pressReleases->links('components.portal.pagination') }}
{{-- ============== KI-PRÜFUNGS-MODAL ============== --}}
<flux:modal name="ai-check-details" class="max-w-2xl">
@if ($aiCheck)
@php
$aiPr = $aiCheck['pr'];
$classificationAudit = $aiCheck['classificationAudit'];
$scoreAudit = $aiCheck['scoreAudit'];
@endphp
<div class="space-y-6" @if ($aiCheck['pending']) wire:poll.3s @endif>
<div>
<flux:heading size="lg">{{ __('KI-Prüfung') }}</flux:heading>
<flux:subheading>
PM-{{ $aiPr->id }} · {{ \Illuminate\Support\Str::limit($aiPr->title, 90) }}
</flux:subheading>
</div>
@if ($aiCheck['pending'])
<div class="flex items-center gap-2 rounded-[6px] border border-[color:var(--color-warn)]/40 bg-[color:var(--color-warn-soft)] px-3 py-2.5 text-[12.5px] text-[color:var(--color-accent-deep)]">
<flux:icon.arrow-path class="size-4 animate-spin" />
{{ __('Prüfung läuft — das Ergebnis erscheint hier automatisch, sobald die Queue durchgelaufen ist.') }}
</div>
@endif
<div class="grid gap-4 sm:grid-cols-2">
{{-- Klassifikation (Rot/Gelb/Grün) --}}
<section class="rounded-[6px] border border-[color:var(--color-line)] p-4 space-y-2">
<span class="section-eyebrow">{{ __('Klassifikation') }}</span>
<div>
@if ($aiPr->classification)
@php
$kiBadge = match ($aiPr->classification) {
\App\Enums\PressReleaseClassification::Green => 'ok',
\App\Enums\PressReleaseClassification::Yellow => 'warn',
\App\Enums\PressReleaseClassification::Red => 'err',
};
@endphp
<span class="badge {{ $kiBadge }} dot">{{ $aiPr->classification->label() }}</span>
@else
<span class="badge muted">{{ __('Noch nicht geprüft') }}</span>
@endif
</div>
@if ($classificationAudit)
@if ($classificationAudit->reason)
<p class="m-0 text-[12.5px] leading-[1.5] text-[color:var(--color-ink-2)] whitespace-pre-line">
{{ $classificationAudit->reason }}
</p>
@elseif ($aiPr->classification === \App\Enums\PressReleaseClassification::Green)
<p class="m-0 text-[12.5px] leading-[1.5] text-[color:var(--color-ink-3)]">
{{ __('Keine Auffälligkeiten — bei Grün liefert die KI keine Begründung.') }}
</p>
@endif
<p class="m-0 text-[11px] text-[color:var(--color-ink-3)]">
{{ $classificationAudit->created_at->format('d.m.Y H:i') }}
· {{ $classificationAudit->provider }}
@if ($classificationAudit->model)
· {{ $classificationAudit->model }}
@endif
</p>
@endif
</section>
{{-- Content-Score --}}
<section class="rounded-[6px] border border-[color:var(--color-line)] p-4 space-y-2">
<span class="section-eyebrow">{{ __('Content-Score') }}</span>
<div>
@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
<span class="badge {{ $tierBadge }}">{{ $aiPr->content_score }} / 100 · {{ $aiPr->content_tier->label() }}</span>
@else
<span class="badge muted">{{ __('Noch nicht bewertet') }}</span>
@endif
</div>
@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
<dl class="m-0 space-y-1.5 pt-1">
@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
<div class="flex items-center gap-2" wire:key="ai-breakdown-{{ $factor }}">
<dt class="w-[110px] flex-shrink-0 text-[11px] text-[color:var(--color-ink-3)]">
{{ $factorLabel }}
</dt>
<dd class="m-0 flex-1">
@if ($factorRatio !== null)
<div class="h-1.5 rounded-full bg-[color:var(--color-line)] overflow-hidden">
<div class="h-full rounded-full bg-[color:var(--color-hub)]" style="width: {{ $factorRatio }}%"></div>
</div>
@endif
</dd>
<dd class="m-0 w-[46px] flex-shrink-0 text-right font-mono text-[11px] tabular-nums text-[color:var(--color-ink-2)]">
{{ $points }}{{ $factorMax !== null ? ' / '.$factorMax : '' }}
</dd>
</div>
@endforeach
</dl>
@endif
@if ($scoreAudit)
<p class="m-0 text-[11px] text-[color:var(--color-ink-3)]">
{{ $scoreAudit->created_at->format('d.m.Y H:i') }}
· {{ $scoreAudit->provider }}
@if ($scoreAudit->model)
· {{ $scoreAudit->model }}
@endif
</p>
@endif
</section>
</div>
<p class="m-0 text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __('Die manuelle Prüfung bewertet und protokolliert nur — der Status der PM bleibt unverändert.') }}
</p>
<div class="flex justify-end space-x-2 rtl:space-x-reverse">
<flux:modal.close>
<flux:button variant="filled">{{ __('Schließen') }}</flux:button>
</flux:modal.close>
<flux:button variant="primary" icon="sparkles" wire:click="runAiCheck"
wire:loading.attr="disabled" :disabled="$aiCheck['pending']">
{{ $aiPr->classification ? __('Erneut prüfen') : __('Prüfung starten') }}
</flux:button>
</div>
</div>
@endif
</flux:modal>
</div>