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:
parent
eb95b3b830
commit
082e6bb76c
11 changed files with 698 additions and 25 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue