`) 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. $classifiedAt = now(); $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; } 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(), ]); self::dispatch($pressRelease->id, $this->route, $this->providerOverride, $currentHash)->onQueue('classification'); return; } // 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(), ]); if (! $this->route && ! $this->redRecheckMustReject($pressRelease, $result)) { return; } try { $service->routeByClassification($pressRelease, $result->classification, $result->reasonText()); } catch (BlacklistViolationException) { // publish() hat die PM bereits abgelehnt und den Autor benachrichtigt. } catch (StaleClassificationException $exception) { // Inhalt zwischen Audit und Publish-Sperre geändert: publish() hat // bereits eine Prüfung für den aktuellen Stand eingeplant (U02). Log::channel('ki')->info('Auto-Veröffentlichung ü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. */ private function redRecheckMustReject(PressRelease $pressRelease, ClassificationResult $result): bool { if ($result->classification !== PressReleaseClassification::Red) { return false; } $pressRelease->refresh(); 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); } } }