presseportale/app/Jobs/ClassifyPressRelease.php
Kevin Adametz c82f279fef press-releases/jobs: Scheduler respektiert manuelle Prüfung und kommt an Blockaden vorbei, Newsletter-Versand per Schalter gesperrt, kurze Scheduler-Sperren (Umsetzungsaudit 29.09.: K-01, K-02, K-05, K-06, K-09, E9, OPT-8, OPT-9; 02.10.: O06)
- K-01: publish-scheduled filtert manual_review_required_at; publish() prüft
  für ki/scheduler unter der Sperre (ManualReviewRequiredException)
- K-02: Kontingent- und Bildsperre sind fachliche Ausgänge (Exit 0, einmaliger
  Verlaufseintrag); KI-Job ohne Retry bei Bildsperre
- O06: Kandidaten per Cursor (scheduled_at, id), Limit zählt bearbeitete
  Meldungen, Blockaden nur ein Scan-Budget
- K-05: KI-Job lädt vor dem Routing neu, überholter Status ohne Retry; backoff
- K-06: Admin-Aktionen mit Permission-Prüfung und Fehlermeldung statt 500
- K-09: withoutOverlapping() mit kurzer Sperrdauer, purge-events 03:50,
  neuer Wochenlauf press-release-images:sweep-orphan-originals (So 04:45)
- E9: NEWSLETTER_SENDING_ENABLED (Standard aus) sperrt Start, Resume und Job
- OPT-9: ExpireGrandfathering prüft die Queue-Voraussetzung aus E18

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:09:00 +00:00

289 lines
14 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Jobs;
use App\Enums\PressReleaseClassification;
use App\Enums\PressReleaseStatus;
use App\Models\KiAudit;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\Classification\ClassificationManager;
use App\Services\PressRelease\Classification\ClassificationResult;
use App\Services\PressRelease\ImageRightsConfirmationRequiredException;
use App\Services\PressRelease\ManualReviewRequiredException;
use App\Services\PressRelease\PressReleaseService;
use App\Services\PressRelease\QuotaExceededException;
use App\Services\PressRelease\StaleClassificationException;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use LogicException;
/**
* Klassifiziert eine Pressemitteilung asynchron (Red Flag, Konzept §15.1).
*
* Läuft auf der dedizierten Queue „classification". Nutzt den konfigurierten
* KI-Treiber; bei Ausfall wird auf den deterministischen Treiber
* zurückgefallen, damit eine Einreichung nie an einer KI-Störung hängen
* bleibt. Ergebnis landet in `press_releases.classification`/`classified_at`
* und als unveränderlicher Eintrag in `ki_audits`.
*
* Das Ergebnis wird gespeichert/auditiert und anschließend über
* PressReleaseService::routeByClassification() in den Status überführt
* (Rot→rejected, Gelb→review, Grün→publish).
*
* Inhaltsbindung (Audit F07): Der Job trägt die Prüfsumme des Inhalts zum
* Zeitpunkt der Einplanung. Hat sich der Text während des KI-Aufrufs
* geändert, wird das Ergebnis verworfen (Audit mit `discarded_at`) und ein
* neuer Lauf für den aktuellen Stand eingeplant – ein altes Urteil darf
* weder eine neue Version bewerten noch freigeben.
*
* Atomar (Audit U02): Das Ergebnis wird mit einem bedingten UPDATE
* (`WHERE content_hash = <geprüfte Summe>`) geschrieben — Vergleich und
* Schreiben sind damit ein Statement, ein Editor kann nicht mehr zwischen
* Hash-SELECT und Speichern dazwischenfunken. Der KI-Aufruf läuft bewusst
* außerhalb jeder Sperre. Query-Builder-Update ist hier unbedenklich: kein
* Observer reagiert auf Klassifikationsfelder (die Suchtabelle spiegelt nur
* Titel/Untertitel/Text/Keywords).
*
* Roter Re-Check (Audit U03): Liefert ein Lauf ohne Routing (`route: false`,
* z. B. nach einer Bearbeitung) Rot und die PM wartet noch in `review` —
* typisch eine terminierte Grün-PM, die vor dem Termin geändert wurde —,
* greift der Rot-Zweig der Erstprüfung (Ablehnung inkl. Autorenmail bzw.
* manuelle Prüfung bei erschöpfter Automatik). Sonst bliebe sie als rote
* terminierte Meldung still liegen, weil der Scheduler nur Grün/Gelb zieht.
* Gelb/Grün ohne Routing und bereits veröffentlichte PMs bleiben unverändert
* (Admin-Signal); eine PM, die ausdrücklich auf einen Menschen wartet
* (`manual_review_required_at`), wird nicht automatisch abgelehnt.
*/
class ClassifyPressRelease implements ShouldQueue
{
use Queueable;
public int $tries = 3;
/**
* Drei HTTP-Versuche à Timeout plus Verbindungsaufbau überschreiten die
* 60 s des Workers; retry_after (config/queue.php) liegt darüber.
*/
public int $timeout = 120;
/**
* Wartezeit vor dem 2. und 3. Versuch (OPT-8): Ein 429 bzw. eine kurze
* Störung des KI-Anbieters soll nicht sofort zweimal nachgeschossen werden.
*
* @return list<int>
*/
public function backoff(): array
{
return [30, 120];
}
/**
* @param int $pressReleaseId Zu prüfende PM
* @param bool $route Ob das Ergebnis den Status steuert (Rot→reject,
* Grün→publish). Beim regulären Einreichen `true`;
* beim manuellen Admin-Re-Check `false` (nur
* klassifizieren/auditieren, Status unverändert).
* @param string|null $providerOverride Optionaler abweichender Treiber
* (z. B. 'openai'|'deterministic') für diesen Lauf.
* @param string|null $contentHash Prüfsumme des Inhalts bei Einplanung;
* null → wird beim Start des Jobs gelesen
* (Admin-Re-Checks, Altbestand in der Queue).
*/
public function __construct(
public readonly int $pressReleaseId,
public readonly bool $route = true,
public readonly ?string $providerOverride = null,
public readonly ?string $contentHash = null,
) {}
public function handle(ClassificationManager $manager, PressReleaseService $service): void
{
$pressRelease = PressRelease::withoutGlobalScope(PortalScope::class)->find($this->pressReleaseId);
if ($pressRelease === null) {
return;
}
$analyzedHash = $this->contentHash ?? $pressRelease->content_hash;
$result = $this->classify($manager, $pressRelease);
// Bedingtes UPDATE: schreibt nur, wenn der Inhalt noch die geprüfte
// Prüfsumme trägt. Altbestand ohne Summe (null) wird unbedingt
// geschrieben. 0 Zeilen = Inhalt inzwischen geändert oder PM weg.
//
// Ergebnis und Audit in EINER Transaktion (Audit N03): Der Scheduler
// liest die Klassifikation und den Audit-Hash getrennt; ein Commit
// zwischen beiden ließe eine grüne PM kurz ohne Nachweis dastehen —
// und „ohne Nachweis" gilt als auditloser Altbestand, der durchgeht.
$classifiedAt = now();
/** @var array{stored: bool, current_hash: string|null} $outcome */
$outcome = DB::transaction(function () use ($pressRelease, $result, $analyzedHash, $classifiedAt): array {
$stored = PressRelease::withoutGlobalScope(PortalScope::class)
->whereKey($pressRelease->id)
->when($analyzedHash !== null, fn ($query) => $query->where('content_hash', $analyzedHash))
->update([
'classification' => $result->classification->value,
'classified_at' => $classifiedAt,
'updated_at' => $classifiedAt,
]);
if ($stored === 0) {
$currentHash = PressRelease::withoutGlobalScope(PortalScope::class)->withTrashed()->whereKey($pressRelease->id)->value('content_hash');
if ($currentHash === null) {
// Hart gelöscht: nichts mehr zu binden, kein Audit möglich (FK).
return ['stored' => false, 'current_hash' => null];
}
KiAudit::query()->create([
'press_release_id' => $pressRelease->id,
'type' => KiAudit::TYPE_CLASSIFICATION,
'provider' => $result->provider,
'model' => $result->model,
'result' => $result->classification->value,
'reason' => 'Verworfen: Inhalt wurde während der Prüfung geändert. '.$result->reasonText(),
'raw_response' => $result->rawResponse,
'content_hash' => $analyzedHash,
'discarded_at' => now(),
'created_at' => now(),
]);
return ['stored' => false, 'current_hash' => $currentHash];
}
// Modell auf den geschriebenen Stand bringen, ohne ein zweites
// (unbedingtes) UPDATE auszulösen.
$pressRelease->forceFill([
'classification' => $result->classification->value,
'classified_at' => $classifiedAt,
])->syncOriginal();
KiAudit::query()->create([
'press_release_id' => $pressRelease->id,
'type' => KiAudit::TYPE_CLASSIFICATION,
'provider' => $result->provider,
'model' => $result->model,
'result' => $result->classification->value,
'reason' => $result->reasonText(),
'raw_response' => $result->rawResponse,
'content_hash' => $analyzedHash,
'created_at' => now(),
]);
return ['stored' => true, 'current_hash' => $analyzedHash];
});
if (! $outcome['stored']) {
if ($outcome['current_hash'] !== null) {
self::dispatch($pressRelease->id, $this->route, $this->providerOverride, $outcome['current_hash'])->onQueue('classification');
}
return;
}
if (! $this->route && ! $this->redRecheckMustReject($pressRelease, $result, $analyzedHash)) {
return;
}
// Status frisch lesen (Audit K-05): Der KI-Aufruf kann gedauert haben;
// hat ein Admin inzwischen entschieden, greift routeByClassification()
// gar nicht erst, statt an einer Statusprüfung zu scheitern.
$pressRelease->refresh();
try {
$service->routeByClassification($pressRelease, $result->classification, $result->reasonText(), $analyzedHash);
} catch (BlacklistViolationException) {
// publish() hat die PM bereits abgelehnt und den Autor benachrichtigt.
} catch (StaleClassificationException $exception) {
// Inhalt zwischen Audit und Sperre geändert: publish() bzw.
// reject()/Hold haben bereits eine Prüfung für den aktuellen
// Stand eingeplant (U02, N04).
Log::channel('ki')->info('Automatisches Routing übersprungen: Klassifikation passt nicht mehr zum Inhalt, Prüfung neu eingeplant.', [
'press_release_id' => $pressRelease->id,
]);
} catch (QuotaExceededException $exception) {
// Kontingent zwischen Einreichung und KI-Freigabe aufgebraucht: PM
// bleibt in Review, damit Autor/Admin bewusst entscheiden — kein
// Job-Retry, der die Klassifikation sinnlos wiederholt.
Log::channel('ki')->info('Auto-Veröffentlichung übersprungen: PM-Kontingent aufgebraucht, PM bleibt in Review.', [
'press_release_id' => $pressRelease->id,
]);
} catch (ImageRightsConfirmationRequiredException) {
// Titelbild wartet auf die Bestätigung des Kontoinhabers (E10,
// Audit K-02): fachlicher Ausgang, kein Retry. Die PM bleibt in
// Review; die Bestätigung hat der Kunde per Mail bekommen.
Log::channel('ki')->info('Auto-Veröffentlichung übersprungen: Titelbild noch nicht bestätigt, PM bleibt in Review.', [
'press_release_id' => $pressRelease->id,
]);
} catch (ManualReviewRequiredException) {
// Wartet auf einen Menschen (E3, Audit K-01): keine Automatik.
Log::channel('ki')->info('Auto-Veröffentlichung übersprungen: PM wartet auf manuelle Prüfung.', [
'press_release_id' => $pressRelease->id,
]);
} catch (LogicException $exception) {
// Status zwischen refresh() und Zeilensperre überholt (Audit K-05):
// die Entscheidung ist gefallen, ein Retry würde nur einen zweiten
// KI-Aufruf und ein doppeltes Audit erzeugen.
Log::channel('ki')->info('Routing überholt: Status wurde während der Prüfung geändert.', [
'press_release_id' => $pressRelease->id,
'error' => $exception->getMessage(),
]);
}
}
/**
* Roter Re-Check ohne Routing einer noch wartenden PM (Audit U03): nur
* dann greift der Rot-Zweig der Erstprüfung. Status frisch lesen — der
* KI-Aufruf kann gedauert haben, und ein Admin hat womöglich inzwischen
* entschieden. Ist der Inhalt seit dem Ergebnis schon wieder geändert
* (Audit N04), gehört das Rot zu einer alten Fassung: nicht ablehnen —
* die Bearbeitung hat bereits einen Re-Check für den neuen Stand
* eingeplant. Verbindlich prüft reject() das noch einmal unter der Sperre.
*/
private function redRecheckMustReject(PressRelease $pressRelease, ClassificationResult $result, ?string $analyzedHash): bool
{
if ($result->classification !== PressReleaseClassification::Red) {
return false;
}
$pressRelease->refresh();
if ($analyzedHash !== null && $pressRelease->content_hash !== null && $pressRelease->content_hash !== $analyzedHash) {
Log::channel('ki')->info('Roter Re-Check übersprungen: Inhalt seit dem Ergebnis geändert, neuer Re-Check läuft.', [
'press_release_id' => $pressRelease->id,
]);
return false;
}
return $pressRelease->status === PressReleaseStatus::Review
&& ! $pressRelease->requiresManualReview();
}
/**
* Klassifiziert über den aktiven (oder explizit gewählten) Treiber; bei
* Fehler greift der deterministische Fallback, damit das Ergebnis
* nachvollziehbar bleibt.
*/
private function classify(ClassificationManager $manager, PressRelease $pressRelease): ClassificationResult
{
$provider = $this->providerOverride ?: $manager->getDefaultDriver();
try {
return $manager->driver($provider)->classify($pressRelease);
} catch (\Throwable $exception) {
Log::channel('ki')->warning('KI-Klassifikation fiel auf den deterministischen Treiber zurück.', [
'press_release_id' => $pressRelease->id,
'provider' => $provider,
'error' => $exception->getMessage(),
]);
return $manager->driver('deterministic')->classify($pressRelease);
}
}
}