presseportale/app/Jobs/ScorePressRelease.php
Kevin Adametz ea68362310 ki/quota: Hash-Bindung atomar bis in den Publish-Lock, roter Re-Check lehnt ab, Kontingent-Altbestand (Block 7, U02–U04)
- Jobs speichern Ergebnis nur per bedingtem UPDATE auf den analysierten Hash;
  publish() prüft bei ki/scheduler unter der Zeilensperre die jüngste gültige
  Klassifikation und plant sonst eine neue Prüfung ein (StaleClassificationException)
- Re-Check ohne Routing: Rot bei einer PM in Review lehnt wie die Erstprüfung ab
- consumePublishSlot() zählt eine NULL-Periode wie den laufenden Monat;
  Backfill-Migration setzt die Periode für Altverbrauch
- ApiPortalResolutionTest: gemeinsame Kategorie wegen der U10-Prüfung

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 12:22:22 +00:00

136 lines
5.3 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\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;
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.
$scoredAt = now();
$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;
}
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(),
]);
self::dispatch($pressRelease->id, $this->providerOverride, $currentHash)->onQueue('classification');
return;
}
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(),
]);
}
/**
* 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::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);
}
}
}