presseportale/app/Jobs/ScorePressRelease.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

159 lines
6.2 KiB
PHP
Raw 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\PressReleaseContentTier;
use App\Models\KiAudit;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Services\PressRelease\ContentScore\ContentScoreManager;
use App\Services\PressRelease\ContentScore\ContentScoreResult;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Berechnet den Content-Score einer Pressemitteilung asynchron (Konzept §15.2).
*
* Läuft auf der Queue „classification" (gemeinsam mit der Klassifikation, ein
* Drain-Befehl). Nutzt den konfigurierten Treiber; bei Ausfall greift der
* deterministische Fallback. Ergebnis: `content_score` + abgeleitete
* `content_tier` + `scored_at`, plus `ki_audits`-Eintrag (type=content_score).
*
* Reine Qualitätsbewertung – ändert nie den Status (das macht die
* Klassifikation).
*
* Inhaltsbindung wie bei ClassifyPressRelease (Audit F07): Ergebnis für
* einen inzwischen geänderten Text wird verworfen und neu eingeplant.
* Atomar per bedingtem UPDATE auf die geprüfte Prüfsumme (Audit U02); der
* KI-Aufruf läuft außerhalb jeder Sperre. Query-Builder-Update ist hier
* unbedenklich: kein Observer reagiert auf Score-Felder.
*/
class ScorePressRelease implements ShouldQueue
{
use Queueable;
public int $tries = 3;
/**
* Drei HTTP-Versuche à 30 s plus Verbindungsaufbau (~91 s) überschreiten
* die 60 s des Workers – der deterministische Fallback käme sonst nie
* zum Zug (Audit F17). 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];
}
public function __construct(
public readonly int $pressReleaseId,
public readonly ?string $providerOverride = null,
public readonly ?string $contentHash = null,
) {}
public function handle(ContentScoreManager $manager): void
{
$pressRelease = PressRelease::withoutGlobalScope(PortalScope::class)->find($this->pressReleaseId);
if ($pressRelease === null) {
return;
}
$analyzedHash = $this->contentHash ?? $pressRelease->content_hash;
$result = $this->score($manager, $pressRelease);
$tier = PressReleaseContentTier::fromScore($result->score);
// Bedingtes UPDATE: schreibt nur, wenn der Inhalt noch die bewertete
// 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 — gleiches Schreibmuster wie
// ClassifyPressRelease (Audit N03), damit Bewertung und Nachweis nie
// getrennt sichtbar werden.
$scoredAt = now();
/** @var string|null $rescheduleFor */
$rescheduleFor = DB::transaction(function () use ($pressRelease, $result, $tier, $analyzedHash, $scoredAt): ?string {
$stored = PressRelease::withoutGlobalScope(PortalScope::class)
->whereKey($pressRelease->id)
->when($analyzedHash !== null, fn ($query) => $query->where('content_hash', $analyzedHash))
->update([
'content_score' => $result->score,
'content_tier' => $tier->value,
'scored_at' => $scoredAt,
'updated_at' => $scoredAt,
]);
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 null;
}
KiAudit::query()->create([
'press_release_id' => $pressRelease->id,
'type' => KiAudit::TYPE_CONTENT_SCORE,
'provider' => $result->provider,
'model' => $result->model,
'result' => (string) $result->score,
'reason' => 'Verworfen: Inhalt wurde während der Bewertung geändert.',
'raw_response' => ['breakdown' => $result->breakdown] + $result->rawResponse,
'content_hash' => $analyzedHash,
'discarded_at' => now(),
'created_at' => now(),
]);
return $currentHash;
}
KiAudit::query()->create([
'press_release_id' => $pressRelease->id,
'type' => KiAudit::TYPE_CONTENT_SCORE,
'provider' => $result->provider,
'model' => $result->model,
'result' => (string) $result->score,
'reason' => $tier->label(),
'raw_response' => ['breakdown' => $result->breakdown] + $result->rawResponse,
'content_hash' => $analyzedHash,
'created_at' => now(),
]);
return null;
});
if ($rescheduleFor !== null) {
self::dispatch($pressRelease->id, $this->providerOverride, $rescheduleFor)->onQueue('classification');
}
}
/**
* Bewertet über den aktiven (oder explizit gewählten) Treiber; bei Fehler
* greift der deterministische Fallback.
*/
private function score(ContentScoreManager $manager, PressRelease $pressRelease): ContentScoreResult
{
$provider = $this->providerOverride ?: $manager->getDefaultDriver();
try {
return $manager->driver($provider)->score($pressRelease);
} catch (\Throwable $exception) {
Log::channel('ki')->warning('Content-Score fiel auf den deterministischen Treiber zurück.', [
'press_release_id' => $pressRelease->id,
'provider' => $provider,
'error' => $exception->getMessage(),
]);
return $manager->driver('deterministic')->score($pressRelease);
}
}
}