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>
This commit is contained in:
Kevin Adametz 2026-09-18 12:22:22 +00:00
parent 1cd9fd11af
commit ea68362310
11 changed files with 698 additions and 25 deletions

View file

@ -2,6 +2,8 @@
namespace App\Jobs;
use App\Enums\PressReleaseClassification;
use App\Enums\PressReleaseStatus;
use App\Models\KiAudit;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
@ -10,6 +12,7 @@ use App\Services\PressRelease\Classification\ClassificationManager;
use App\Services\PressRelease\Classification\ClassificationResult;
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\Log;
@ -32,6 +35,24 @@ use Illuminate\Support\Facades\Log;
* 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
{
@ -75,11 +96,27 @@ class ClassifyPressRelease implements ShouldQueue
$analyzedHash = $this->contentHash ?? $pressRelease->content_hash;
$result = $this->classify($manager, $pressRelease);
// Inhalt seit der Einplanung geändert? Dann gilt das Urteil für einen
// Text, den es nicht mehr gibt: verwerfen und den aktuellen prüfen.
$currentHash = PressRelease::withoutGlobalScope(PortalScope::class)->whereKey($pressRelease->id)->value('content_hash');
// 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;
}
if ($analyzedHash !== null && $currentHash !== null && $currentHash !== $analyzedHash) {
KiAudit::query()->create([
'press_release_id' => $pressRelease->id,
'type' => KiAudit::TYPE_CLASSIFICATION,
@ -98,10 +135,12 @@ class ClassifyPressRelease implements ShouldQueue
return;
}
// Modell auf den geschriebenen Stand bringen, ohne ein zweites
// (unbedingtes) UPDATE auszulösen.
$pressRelease->forceFill([
'classification' => $result->classification->value,
'classified_at' => now(),
])->save();
'classified_at' => $classifiedAt,
])->syncOriginal();
KiAudit::query()->create([
'press_release_id' => $pressRelease->id,
@ -115,7 +154,7 @@ class ClassifyPressRelease implements ShouldQueue
'created_at' => now(),
]);
if (! $this->route) {
if (! $this->route && ! $this->redRecheckMustReject($pressRelease, $result)) {
return;
}
@ -123,6 +162,12 @@ class ClassifyPressRelease implements ShouldQueue
$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::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
@ -133,6 +178,24 @@ class ClassifyPressRelease implements ShouldQueue
}
}
/**
* 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

View file

@ -25,6 +25,9 @@ use Illuminate\Support\Facades\Log;
*
* 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
{
@ -57,9 +60,28 @@ class ScorePressRelease implements ShouldQueue
$result = $this->score($manager, $pressRelease);
$tier = PressReleaseContentTier::fromScore($result->score);
$currentHash = PressRelease::withoutGlobalScope(PortalScope::class)->whereKey($pressRelease->id)->value('content_hash');
// 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;
}
if ($analyzedHash !== null && $currentHash !== null && $currentHash !== $analyzedHash) {
KiAudit::query()->create([
'press_release_id' => $pressRelease->id,
'type' => KiAudit::TYPE_CONTENT_SCORE,
@ -78,12 +100,6 @@ class ScorePressRelease implements ShouldQueue
return;
}
$pressRelease->forceFill([
'content_score' => $result->score,
'content_tier' => $tier->value,
'scored_at' => now(),
])->save();
KiAudit::query()->create([
'press_release_id' => $pressRelease->id,
'type' => KiAudit::TYPE_CONTENT_SCORE,