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 eb95b3b830
commit 082e6bb76c
11 changed files with 698 additions and 25 deletions

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,