`) 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; /** * @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; } 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, ]); } } /** * 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); } } }