*/ public const CORRECTABLE_FIELDS = ['title', 'subtitle', 'text', 'keywords']; public function __construct( private readonly ActivityLog $activityLog, private readonly PressReleaseService $pressReleaseService, private readonly PressReleaseHtmlSanitizer $sanitizer, private readonly ClassificationManager $classification, ) {} /** * KI-Vorprüfung des GEÄNDERTEN Inhalts (Spur D, vor der Zahlung): läuft * auf einer nicht gespeicherten Kopie der PM mit den vorgeschlagenen * Werten. Rot → keine Zahlung, Einreichung wird abgelehnt. Fällt der * KI-Anbieter aus, greift der deterministische Fallback (Blacklist) — * wie beim regulären Freigabelauf. * * @param array $newValues */ public function precheckProposedContent(PressRelease $pressRelease, array $newValues): ClassificationResult { $proposed = $pressRelease->replicate(); foreach (self::CORRECTABLE_FIELDS as $field) { if (array_key_exists($field, $newValues)) { $value = (string) ($newValues[$field] ?? ''); $proposed->setAttribute($field, $value !== '' ? $value : null); } } try { return $this->classification->driver()->classify($proposed); } catch (Throwable $e) { Log::channel('ki')->warning('KI-Vorprüfung (Spur D) fehlgeschlagen — deterministischer Fallback.', [ 'press_release_id' => $pressRelease->id, 'error' => $e->getMessage(), ]); return $this->classification->driver('deterministic')->classify($proposed); } } /** * Einstufung klein/groß je Feld — verändert nichts. * * `exceeds_paid_limit`: oberhalb von legal.change_max_distance_percent * ist auch die kostenpflichtige Änderung (Spur D) nicht mehr buchbar — * sonst ließe sich der Text faktisch selbst depublizieren. * * @param array $newValues * @return array{fields: array, max_distance_percent: float, has_changes: bool, is_minor: bool, exceeds_paid_limit: bool} */ public function classify(PressRelease $pressRelease, array $newValues): array { $threshold = (int) config('legal.correction_max_distance_percent'); $fields = []; $maxPercent = 0.0; $hasChanges = false; $allMinor = true; foreach (self::CORRECTABLE_FIELDS as $field) { if (! array_key_exists($field, $newValues)) { continue; } $old = $this->comparableText((string) ($pressRelease->getAttribute($field) ?? '')); $new = $this->comparableText((string) ($newValues[$field] ?? '')); if ($old === $new) { $fields[$field] = ['distance_percent' => 0.0, 'changed' => false]; continue; } $percent = $this->distancePercent($old, $new); // Mini-Edit-Ausnahme: bis 2 Zeichen ist es immer ein Tippfehler-Fix // — sonst würde bei kurzen Titeln schon ein einzelner Buchstabe // die Prozent-Grenze reißen und Spur A ins Leere laufen. $isMinorField = $percent <= $threshold || $this->isTinyEdit($old, $new); $fields[$field] = ['distance_percent' => $percent, 'changed' => true]; $maxPercent = max($maxPercent, $percent); $hasChanges = true; $allMinor = $allMinor && $isMinorField; } return [ 'fields' => $fields, 'max_distance_percent' => $maxPercent, 'has_changes' => $hasChanges, 'is_minor' => $hasChanges && $allMinor, 'exceeds_paid_limit' => $maxPercent > (int) config('legal.change_max_distance_percent'), ]; } /** * Führt eine kleine Korrektur aus: Snapshot (Rückgängig-Option) → * Felder schreiben → Re-Sync-Sperre → Verlauf → Re-Score ohne * Status-Wirkung. Wirft, wenn das Gate reißt oder die Bremse greift. * * @param array $newValues * * @throws CorrectionTooLargeException * @throws CorrectionRateLimitException */ public function apply( PressRelease $pressRelease, array $newValues, string $requesterEmail, LegalRequestVerificationMethod $method, ?User $actor = null, ): LegalRequest { $classification = $this->classify($pressRelease, $newValues); if (! $classification['is_minor']) { throw new CorrectionTooLargeException; } $this->assertNotRateLimited($pressRelease); [$legalRequest, $snapshot, $job] = DB::transaction(function () use ($pressRelease, $newValues, $requesterEmail, $method, $classification, $actor): array { $legalRequest = LegalRequest::query()->create([ 'press_release_id' => $pressRelease->id, 'portal' => $pressRelease->portal?->value, 'type' => LegalRequestType::Correction->value, 'status' => LegalRequestStatus::Resolved->value, 'requester_email' => $requesterEmail, 'message' => null, 'payload' => [ 'source' => 'self_service', 'fields' => $classification['fields'], ], 'verified_at' => now(), 'verification_method' => $method->value, 'resolved_at' => now(), 'executed_at' => now(), ]); $snapshot = PressReleaseRedactionSnapshot::capture($pressRelease, [ 'fields' => $pressRelease->only(self::CORRECTABLE_FIELDS), ], $legalRequest); $this->writeChangedFields($pressRelease, $classification['fields'], $newValues); $pressRelease->save(); $pressRelease->lockSync('Kleine Korrektur '.$legalRequest->reference()); $job = $this->queueArchive($pressRelease, $snapshot, $legalRequest, $actor, 'Kleine Korrektur (Selbstbedienung)'); return [$legalRequest, $snapshot, $job]; }); WebCaches::forgetSitemaps(); $entry = $this->archiveChange($pressRelease, $job, $snapshot, $legalRequest, $actor); $changedFields = collect($classification['fields']) ->filter(fn (array $meta): bool => $meta['changed']) ->keys() ->implode(', '); $this->activityLog->record( ActivityType::Correction, sprintf( 'Kleine Korrektur (%s) gespeichert: Feld(er) %s, max. Distanz %.1f %%.', $legalRequest->reference(), $changedFields, $classification['max_distance_percent'], ), user: $actor, subject: $pressRelease, meta: ['legal_request_id' => $legalRequest->id, 'archive_entry_id' => $entry?->getKey()], ); // Re-Check ohne Status-Wirkung — konsistent mit dem bestehenden // Edit-Verhalten; Spur A hat bewusst kein Freigabelauf-Gate. $this->pressReleaseService->reprocessAfterContentChange($pressRelease); return $legalRequest; } /** * Führt eine BEZAHLTE Änderung (Spur D) nach Zahlungseingang aus: die * neuen Werte kommen aus dem Payload der bei der Vorprüfung angelegten * Change-Anfrage. Kein Minor-Gate und keine Wochen-Bremse — dafür die * absolute Obergrenze (legal.change_max_distance_percent) und die * bereits bestandene KI-Vorprüfung. Geht sofort live. * * @throws CorrectionTooLargeException wenn die Obergrenze doch reißt */ public function applyPaid(PressRelease $pressRelease, LegalRequest $legalRequest, ?User $actor = null): bool { /** @var array $newValues */ $newValues = (array) ($legalRequest->payload['new_values'] ?? []); $classification = $this->classify($pressRelease, $newValues); if ($classification['exceeds_paid_limit']) { throw new CorrectionTooLargeException; } if (! $classification['has_changes']) { Log::channel('billing')->warning('Bezahlte Änderung ohne wirksame Feld-Änderung — nichts zu tun.', [ 'legal_request_id' => $legalRequest->id, 'press_release_id' => $pressRelease->id, ]); return false; } [$snapshot, $job] = DB::transaction(function () use ($pressRelease, $legalRequest, $classification, $newValues, $actor): array { $legalRequest->forceFill([ 'status' => LegalRequestStatus::Resolved->value, 'payload' => array_merge($legalRequest->payload ?? [], ['fields' => $classification['fields']]), 'resolved_at' => now(), 'executed_at' => now(), ])->save(); $snapshot = PressReleaseRedactionSnapshot::capture($pressRelease, [ 'fields' => $pressRelease->only(self::CORRECTABLE_FIELDS), ], $legalRequest); $this->writeChangedFields($pressRelease, $classification['fields'], $newValues); $pressRelease->save(); $pressRelease->lockSync('Kostenpflichtige Änderung '.$legalRequest->reference()); $job = $this->queueArchive($pressRelease, $snapshot, $legalRequest, $actor, 'Kostenpflichtige Änderung'); return [$snapshot, $job]; }); WebCaches::forgetSitemaps(); $entry = $this->archiveChange($pressRelease, $job, $snapshot, $legalRequest, $actor); $changedFields = collect($classification['fields']) ->filter(fn (array $meta): bool => $meta['changed']) ->keys() ->implode(', '); $this->activityLog->record( ActivityType::Correction, sprintf( 'Kostenpflichtige Änderung (%s) nach Zahlungseingang übernommen: Feld(er) %s, max. Distanz %.1f %%.', $legalRequest->reference(), $changedFields, $classification['max_distance_percent'], ), user: $actor, subject: $pressRelease, meta: ['legal_request_id' => $legalRequest->id, 'archive_entry_id' => $entry?->getKey()], ); $this->pressReleaseService->reprocessAfterContentChange($pressRelease); return true; } /** * Beweisarchiv (F.4): Auftrag in der fachlichen Transaktion mit * Vorher-Stand aus dem Snapshot und Nachher-Stand dieser Änderung * (Umsetzungsaudit 29.09.2026, A-02/A-04). */ private function queueArchive(PressRelease $pressRelease, PressReleaseRedactionSnapshot $snapshot, LegalRequest $legalRequest, ?User $actor, string $note): ArchivePendingJob { return app(ArchivePendingQueue::class)->push( (int) $pressRelease->getKey(), ArchiveEvent::GdprChange, $snapshot, ArchiveReasonCategory::Correction, $legalRequest->getKey(), $actor?->getKey(), $note, [ 'before' => (array) ($snapshot->encrypted_before['fields'] ?? []), 'after' => $pressRelease->only(self::CORRECTABLE_FIELDS), ], ); } /** * Eintrag nach dem Commit schreiben. Scheitert die Ablage, bleibt der * Auftrag offen und `archive:retry-pending` holt ihn nach (N11). */ private function archiveChange(PressRelease $pressRelease, ArchivePendingJob $job, PressReleaseRedactionSnapshot $snapshot, LegalRequest $legalRequest, ?User $actor): ?ArchiveEntry { $payload = (array) $job->payload; return app(ArchivePendingQueue::class)->completeWith($job, fn () => app(PressReleaseArchiver::class)->gdprChange( $pressRelease, (array) ($payload['before'] ?? []), (array) ($payload['after'] ?? []), $legalRequest, $actor, ArchiveReasonCategory::Correction, $job->note, $snapshot->getKey(), )); } /** * Schreibt die geänderten Felder (Text durch den Sanitizer) auf die PM. * * @param array $classifiedFields * @param array $newValues */ private function writeChangedFields(PressRelease $pressRelease, array $classifiedFields, array $newValues): void { foreach ($classifiedFields as $field => $meta) { if (! $meta['changed']) { continue; } $value = (string) ($newValues[$field] ?? ''); if ($field === 'text' && $this->sanitizer->isHtml($value)) { $value = $this->sanitizer->clean($value); } $pressRelease->setAttribute($field, $value !== '' ? $value : null); } $pressRelease->save(); } /** * Rückgängig machen (Admin): stellt die Feld-Werte aus dem verschlüsselten * Snapshot wieder her und hängt gelöste Kontakte wieder an. Bilder werden * nur wiederhergestellt, wenn ihre Dateien noch existieren (nicht public * gelöscht). Die Re-Sync-Sperre bleibt bestehen. */ public function restoreSnapshot(PressReleaseRedactionSnapshot $snapshot, ?User $actor = null): void { $pressRelease = $snapshot->pressRelease()->withoutGlobalScopes()->firstOrFail(); $before = $snapshot->encrypted_before; DB::transaction(function () use ($pressRelease, $before): void { foreach ((array) ($before['fields'] ?? []) as $field => $value) { $pressRelease->setAttribute($field, $value); } $pressRelease->save(); $contactIds = (array) ($before['contact_ids'] ?? []); if ($contactIds !== []) { $pressRelease->contacts()->syncWithoutDetaching($contactIds); } foreach ((array) ($before['images'] ?? []) as $imageMeta) { if (($imageMeta['disk'] ?? null) === 'public') { continue; // Datei wurde physisch gelöscht — Zeile bliebe kaputt. } $pressRelease->images()->withTrashed()->whereKey($imageMeta['id'] ?? 0)->restore(); } }); WebCaches::forgetSitemaps(); $this->activityLog->record( ActivityType::Correction, sprintf('Snapshot #%d wiederhergestellt — Vorher-Stand der PM zurückgespielt.', $snapshot->id), user: $actor, subject: $pressRelease, meta: ['snapshot_id' => $snapshot->id, 'legal_request_id' => $snapshot->legal_request_id], ); } /** * @throws CorrectionRateLimitException */ private function assertNotRateLimited(PressRelease $pressRelease): void { $recent = LegalRequest::query() ->where('press_release_id', $pressRelease->id) ->where('type', LegalRequestType::Correction->value) ->whereNotNull('executed_at') ->where('executed_at', '>=', now()->subWeek()) ->count(); if ($recent >= (int) config('legal.corrections_per_week')) { throw new CorrectionRateLimitException; } } /** * Bis 2 Zeichen Distanz ist jede Änderung ein Tippfehler-Fix (nur für * Werte im levenshtein()-Limit von 255 Bytes bestimmbar). */ private function isTinyEdit(string $old, string $new): bool { return strlen($old) <= 255 && strlen($new) <= 255 && levenshtein($old, $new) <= 2; } /** * Vergleichsbasis: HTML raus, Entities dekodiert, Whitespace normalisiert * — reine Markup-Änderungen zählen nicht als inhaltliche Distanz. Wichtig, * weil der Editor Bestands-HTML normalisiert (  → Leerzeichen, * & → &,
→ Absätze): ohne Dekodierung zählte jedes betroffene * Wort als Änderung und bläht die Distanz auf. */ private function comparableText(string $value): string { // Blockgrenzen und
als Leerzeichen erhalten, damit strip_tags // angrenzende Wörter nicht zu einem verklebt. $spaced = preg_replace('#]*>|#i', ' ', $value) ?? $value; $plain = strip_tags($spaced); $decoded = html_entity_decode($plain, ENT_QUOTES | ENT_HTML5, 'UTF-8'); $normalized = str_replace(["\u{00A0}", "\u{202F}", "\u{2007}"], ' ', $decoded); $collapsed = preg_replace('/\s+/u', ' ', $normalized) ?? $normalized; return trim($collapsed); } /** * Relative Änderungsdistanz in Prozent. Kurze Felder zeichenbasiert * (ein Tippfehler im Titel bleibt „klein"), lange Texte wortbasiert — * PHPs levenshtein() ist auf 255 Bytes begrenzt und zeichenweises DP * wäre bei langen Texten zu teuer. */ private function distancePercent(string $old, string $new): float { if (strlen($old) <= 255 && strlen($new) <= 255) { $max = max(mb_strlen($old), mb_strlen($new), 1); return round(levenshtein($old, $new) / $max * 100, 1); } $oldWords = preg_split('/\s+/u', $old, -1, PREG_SPLIT_NO_EMPTY) ?: []; $newWords = preg_split('/\s+/u', $new, -1, PREG_SPLIT_NO_EMPTY) ?: []; $max = max(count($oldWords), count($newWords), 1); $lengthDiffPercent = abs(count($oldWords) - count($newWords)) / $max * 100; // Die Längendifferenz ist eine untere Schranke der Distanz — ab 50 % // ist das sicher „groß", das teure DP kann entfallen. Ebenso bei // extrem langen Texten (DP-Kostengrenze). if ($lengthDiffPercent >= 50.0 || $max > 20000) { return max(round($lengthDiffPercent, 1), 50.0); } return round($this->wordLevenshtein($oldWords, $newWords) / $max * 100, 1); } /** * Klassisches Levenshtein-DP auf Wort-Ebene (zwei Zeilen Speicher). * * @param list $a * @param list $b */ private function wordLevenshtein(array $a, array $b): int { $countA = count($a); $countB = count($b); if ($countA === 0) { return $countB; } if ($countB === 0) { return $countA; } $previous = range(0, $countB); for ($i = 1; $i <= $countA; $i++) { $current = [$i]; for ($j = 1; $j <= $countB; $j++) { $current[$j] = min( $previous[$j] + 1, $current[$j - 1] + 1, $previous[$j - 1] + ($a[$i - 1] === $b[$j - 1] ? 0 : 1), ); } $previous = $current; } return $previous[$countB]; } }