presseportale/app/Services/PressRelease/ViewDistributionReport.php
Kevin Adametz c497628af3 reports: Basiswerte für die Konversionsmessung (Roadmap 0.1 und 0.2)
Beide Werte müssen gemessen sein, bevor die erste Maßnahme aus Block 1 live
geht — ohne Vergleichspunkt wäre jede spätere Bewertung eine Vermutung.

press-releases:view-distribution gibt die Verteilung der Aufrufzahlen je Portal
aus: Median, Quartile, P10/P90 und eine Häufigkeitsverteilung. Ein Mittelwert
allein trägt hier nicht — eine Handvoll Ausreißer zieht ihn nach oben, während
der typische Absender darunter liegt. Genau davor soll E2 schützen, deshalb
wird die Schiefe (Mittelwert gegen Median) ausdrücklich mit ausgewiesen.

Altbestand und seit dem Relaunch eingegangene Meldungen stehen getrennt: Ihre
Zähler haben unterschiedliche Vorgeschichten, und eine Einordnung, die beide
vermischt, vergleicht Äpfel mit Birnen. Mit 410 entfernte Meldungen zählen
nicht mit, eine Meldung auf beiden Portalen bei beiden.

press-releases:rejection-rate gibt die Ablehnungsquote je Einreichungsquelle
aus. Gezählt wird je EINREICHUNG, nicht je Meldung: Ein Übergang nach `review`
eröffnet eine Einreichung, die nächste Entscheidung schließt sie. Eine
überarbeitete und erneut eingereichte Meldung ist damit ein zweiter Vorgang mit
eigenem Ausgang — und genau das ist die Größe, die die Infoseite (1d) später
verändern soll. Gruppiert wird nach dem Einreichenden, nicht nach dem
Entscheider; letzterer ist immer die KI-Prüfung und als Gruppierung wertlos.
Jeder API-Client bekommt eine eigene Zeile, Einreichungen aus dem Kundenbereich
fallen zu "direkt" zusammen.

Noch offene Prüfungen zählen nicht in den Nenner, sonst sänke die Quote allein
dadurch, dass der Zeitraum bis heute reicht. Entscheidungen zu Einreichungen
von vor dem Zeitraum werden gemeldet statt verschluckt — ein zu kurz gewählter
Zeitraum sähe sonst wie eine niedrige Quote aus.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 11:57:25 +00:00

171 lines
6 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\Services\PressRelease;
use App\Enums\Portal;
use App\Enums\PressReleaseStatus;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
/**
* Basiswert der Aufrufzahlen (Roadmap 0.1): Verteilung der Aufrufe je Portal
* über einen Zeitraum.
*
* Wozu: Die geplante Absenderansicht (1c.1) macht öffentlich, was bisher
* niemand belegen musste. Fällt die Verteilung schwach aus, werden Rohzahlen
* zum Nachteil und es braucht eine Einordnung gegen den Portalschnitt — das
* ist Entscheidung E2, und sie muss VOR dem Bau fallen. Dafür reicht kein
* Mittelwert: Eine Handvoll Ausreißer zieht ihn nach oben, während der
* typische Absender darunter liegt. Maßgeblich ist der Median.
*
* Der Mittelwert wird trotzdem mitgeführt, aber nur, damit die Schiefe der
* Verteilung sichtbar wird: Liegt er deutlich über dem Median, ist genau der
* Fall eingetreten, vor dem E2 schützen soll.
*
* Gezählt wird `press_releases.hits` — der Gesamtzähler. Das Ereignis-Log
* taugt für diesen Basiswert nicht: Es läuft erst seit dem Umbau des
* Aufruf-Zählers und kennt den Altbestand nicht.
*
* Legacy-Bestand und seit dem Relaunch eingegangene Meldungen werden getrennt
* ausgewiesen. Ihre Zähler haben unterschiedliche Vorgeschichten (im
* Altsystem gesammelt gegenüber hier gezählt), und eine Einordnung, die beide
* vermischt, vergleicht Äpfel mit Birnen.
*/
class ViewDistributionReport
{
/**
* Grenzen der Häufigkeitsverteilung (Untergrenze je Klasse).
*
* @var list<int>
*/
private const Buckets = [0, 25, 50, 100, 200, 500, 1000];
/**
* @return array{
* from: string,
* to: string,
* portals: list<array{portal: string, label: string, segments: list<array{segment: string, stats: array{anzahl: int, min?: int, p10?: int, p25?: int, median?: int, p75?: int, p90?: int, max?: int, mittelwert?: float, verteilung?: array<string, int>}}>}>,
* }
*/
public function build(Carbon $from, ?Carbon $to = null): array
{
$to ??= Carbon::now();
$portals = [];
foreach ([Portal::Presseecho, Portal::Businessportal24] as $portal) {
$segments = [];
foreach (['gesamt' => null, 'seit Relaunch' => true, 'Altbestand' => false] as $label => $onlyNew) {
$hits = $this->hits($portal, $from, $to, $onlyNew);
if ($hits === [] && $label !== 'gesamt') {
continue;
}
$segments[] = ['segment' => $label, 'stats' => $this->describe($hits)];
}
$portals[] = [
'portal' => $portal->value,
'label' => $portal->label(),
'segments' => $segments,
];
}
return [
'from' => $from->toDateString(),
'to' => $to->toDateString(),
'portals' => $portals,
];
}
/**
* Aufrufzahlen der im Zeitraum veröffentlichten Meldungen.
*
* Eine Meldung mit `portal = both` erscheint auf beiden Portalen und zählt
* deshalb bei beiden mit — der Zähler ist einer, die Sichtbarkeit doppelt.
*
* @return list<int>
*/
private function hits(Portal $portal, Carbon $from, Carbon $to, ?bool $onlyNew): array
{
return PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->whereIn('portal', [$portal->value, Portal::Both->value])
->where('status', PressReleaseStatus::Published->value)
->whereNull('gone_at')
->whereBetween('published_at', [$from, $to])
->when($onlyNew === true, fn (Builder $query) => $query->whereNull('legacy_portal'))
->when($onlyNew === false, fn (Builder $query) => $query->whereNotNull('legacy_portal'))
->orderBy('hits')
->pluck('hits')
->map(static fn ($hits): int => (int) $hits)
->all();
}
/**
* @param list<int> $hits aufsteigend sortiert
* @return array{anzahl: int, min?: int, p10?: int, p25?: int, median?: int, p75?: int, p90?: int, max?: int, mittelwert?: float, verteilung?: array<string, int>}
*/
private function describe(array $hits): array
{
$count = count($hits);
if ($count === 0) {
return ['anzahl' => 0];
}
return [
'anzahl' => $count,
'min' => $hits[0],
'p10' => $this->percentile($hits, 0.10),
'p25' => $this->percentile($hits, 0.25),
'median' => $this->percentile($hits, 0.50),
'p75' => $this->percentile($hits, 0.75),
'p90' => $this->percentile($hits, 0.90),
'max' => $hits[$count - 1],
'mittelwert' => round(array_sum($hits) / $count, 1),
'verteilung' => $this->histogram($hits),
];
}
/**
* Perzentil nach der „nearest rank"-Methode: Der zurückgegebene Wert ist
* immer eine tatsächlich gemessene Aufrufzahl, kein interpolierter
* Zwischenwert. Für eine Einordnung („die Hälfte der Meldungen liegt
* darunter") ist das die ehrlichere Angabe.
*
* @param list<int> $sorted
*/
private function percentile(array $sorted, float $fraction): int
{
$rank = (int) ceil($fraction * count($sorted));
return $sorted[max(0, $rank - 1)];
}
/**
* @param list<int> $hits
* @return array<string, int>
*/
private function histogram(array $hits): array
{
$bounds = self::Buckets;
$result = [];
foreach ($bounds as $i => $lower) {
$upper = $bounds[$i + 1] ?? null;
$label = $upper === null ? "{$lower}+" : "{$lower}–".($upper - 1);
$result[$label] = count(array_filter(
$hits,
static fn (int $h): bool => $h >= $lower && ($upper === null || $h < $upper),
));
}
return $result;
}
}