reports: Aufrufe nach Alter aufschlüsseln und Basiswerte festhalten
Der Gesamtmedian über ein Zeitfenster taugt nicht als Basiswert. `hits` ist ein
mitlaufender Gesamtzähler, und die Aufrufe wachsen nach der Veröffentlichung
über Wochen weiter — auf dem Bestand liegt der Schnitt in der ersten Woche bei
rund 75 und nach drei Wochen bei rund 230, ohne dass ein Plateau erreicht wäre.
Gemessen wird damit nicht, wie viele Aufrufe eine Meldung bekommt, sondern wie
viel eine zufällig alte Meldung bisher gesammelt hat. Dieser Wert verschiebt
sich mit der Altersmischung des Fensters; zwei Messungen zu verschiedenen
Zeitpunkten wären nicht vergleichbar, und genau das braucht ein Basiswert.
Der Report weist die Aufrufe deshalb zusätzlich nach Alterskohorten aus. Erst
die einzelne Kohorte ("Median der Meldungen im Alter von 28 bis 55 Tagen") ist
in einem halben Jahr identisch erhebbar. Der Altbestand bleibt außen vor — seine
Zähler stammen aus dem Altsystem und haben keine hier nachvollziehbare
Vorgeschichte.
Dazu die am 01.09. auf Live erhobenen Basiswerte und die daraus folgende
Empfehlung zu E2: Rohzahlen. Es gibt keinen Bodensatz, den eine Einordnung
kaschieren müsste (unter 25 Aufrufen liegen 2 % beziehungsweise 0 % der
Meldungen), die Verteilungen sind nahezu symmetrisch, und eine Einordnung gegen
den Portaldurchschnitt wäre wegen der Wachstumskurve sogar falsch — sie
verglich eine zwei Tage alte Meldung gegen einen Schnitt aus drei Monaten.
Die Roadmap führte openPR als zweite Zulaufquelle. Das ist korrigiert: openPR
ist der Presse-API-Verteiler von connektar, also derselbe Zulauf. In den Daten
hat genau ein API-Token je einen Zugriff gemacht.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c497628af3
commit
748a0d4b7e
5 changed files with 257 additions and 3 deletions
|
|
@ -98,6 +98,26 @@ class ReportPressReleaseViews extends Command
|
|||
}
|
||||
|
||||
$this->table(['Klasse', 'PMs', 'Anteil'], $verteilung);
|
||||
|
||||
$kohorten = [];
|
||||
|
||||
foreach ($portal['cohorts'] as $cohort) {
|
||||
$kohorten[] = [
|
||||
$cohort['alter'],
|
||||
$cohort['anzahl'],
|
||||
$cohort['median'] ?? '–',
|
||||
$cohort['mittelwert'] ?? '–',
|
||||
];
|
||||
}
|
||||
|
||||
if ($kohorten !== []) {
|
||||
$this->line(' <comment>Aufrufe nach Alter (nur Meldungen seit dem Relaunch)</comment>');
|
||||
$this->table(['Alter der Meldung', 'PMs', 'Median', 'Mittelwert'], $kohorten);
|
||||
// Der Gesamtmedian oben hängt an der Altersmischung des
|
||||
// Fensters und ist zwischen zwei Messzeitpunkten nicht
|
||||
// vergleichbar. Die einzelne Kohorte ist es.
|
||||
$this->line(' Als Basiswert die KOHORTE festhalten, nicht den Gesamtmedian — nur sie ist später identisch erhebbar.');
|
||||
}
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
|
|
|
|||
|
|
@ -42,11 +42,18 @@ class ViewDistributionReport
|
|||
*/
|
||||
private const Buckets = [0, 25, 50, 100, 200, 500, 1000];
|
||||
|
||||
/**
|
||||
* Alterskohorten in Tagen seit Veröffentlichung (Untergrenze je Klasse).
|
||||
*
|
||||
* @var list<int>
|
||||
*/
|
||||
private const Cohorts = [0, 7, 14, 21, 28, 56, 91];
|
||||
|
||||
/**
|
||||
* @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>}}>}>,
|
||||
* 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>}}>, cohorts: list<array{alter: string, anzahl: int, median: int|null, mittelwert: float|null}>}>,
|
||||
* }
|
||||
*/
|
||||
public function build(Carbon $from, ?Carbon $to = null): array
|
||||
|
|
@ -72,6 +79,7 @@ class ViewDistributionReport
|
|||
'portal' => $portal->value,
|
||||
'label' => $portal->label(),
|
||||
'segments' => $segments,
|
||||
'cohorts' => $this->cohorts($portal, $from, $to),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -82,6 +90,58 @@ class ViewDistributionReport
|
|||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Aufrufe nach Alter der Meldung.
|
||||
*
|
||||
* Der Grund, warum es diese Aufschlüsselung gibt: `hits` ist ein
|
||||
* mitlaufender Gesamtzähler, und die Aufrufe wachsen nach der
|
||||
* Veröffentlichung über Wochen weiter. Eine Kennzahl über ein
|
||||
* Drei-Monats-Fenster misst deshalb nicht, wie viele Aufrufe eine Meldung
|
||||
* bekommt, sondern wie viel eine zufällig alte Meldung bisher gesammelt
|
||||
* hat — und dieser Wert verschiebt sich mit der Altersmischung des
|
||||
* Fensters. Zwei Messungen zu verschiedenen Zeitpunkten wären damit nicht
|
||||
* vergleichbar, und genau das braucht der Basiswert.
|
||||
*
|
||||
* Vergleichbar ist erst die einzelne Kohorte: „Median der Meldungen im
|
||||
* Alter von 28 bis 55 Tagen" lässt sich in einem halben Jahr identisch
|
||||
* erheben. DIESE Zeile ist der Basiswert, nicht die Gesamtzahl.
|
||||
*
|
||||
* Nur Meldungen seit dem Relaunch — die Zähler des Altbestands stammen aus
|
||||
* dem Altsystem und haben keine hier nachvollziehbare Vorgeschichte.
|
||||
*
|
||||
* @return list<array{alter: string, anzahl: int, median: int|null, mittelwert: float|null}>
|
||||
*/
|
||||
private function cohorts(Portal $portal, Carbon $from, Carbon $to): array
|
||||
{
|
||||
$bounds = self::Cohorts;
|
||||
$result = [];
|
||||
|
||||
foreach ($bounds as $i => $lowerDays) {
|
||||
$upperDays = $bounds[$i + 1] ?? null;
|
||||
|
||||
// Älter heißt: früher veröffentlicht. Die Altersgrenzen drehen sich
|
||||
// deshalb zu Datumsgrenzen um.
|
||||
$newest = $to->copy()->subDays($lowerDays);
|
||||
$oldest = $upperDays === null ? $from : $to->copy()->subDays($upperDays - 1);
|
||||
|
||||
if ($oldest->greaterThan($newest)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$hits = $this->hits($portal, $oldest->max($from), $newest->min($to), true);
|
||||
$count = count($hits);
|
||||
|
||||
$result[] = [
|
||||
'alter' => $upperDays === null ? "{$lowerDays}+ Tage" : "{$lowerDays}–".($upperDays - 1).' Tage',
|
||||
'anzahl' => $count,
|
||||
'median' => $count > 0 ? $this->percentile($hits, 0.50) : null,
|
||||
'mittelwert' => $count > 0 ? round(array_sum($hits) / $count, 1) : null,
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aufrufzahlen der im Zeitraum veröffentlichten Meldungen.
|
||||
*
|
||||
|
|
|
|||
137
dev/2026-09-01/basiswerte-block0.md
Normal file
137
dev/2026-09-01/basiswerte-block0.md
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
# Basiswerte Block 0
|
||||
|
||||
**Erhoben:** 01.09.2026 auf dem Live-System
|
||||
**Fenster:** 01.06.2026 bis 01.09.2026
|
||||
**Erhoben mit:** `press-releases:view-distribution`, `press-releases:rejection-rate`
|
||||
|
||||
Diese Werte sind der Vergleichspunkt für alles, was aus Block 1 folgt. Sie
|
||||
stehen fest, bevor die erste Maßnahme live geht — genau dafür existiert
|
||||
Block 0.
|
||||
|
||||
---
|
||||
|
||||
## 0.1 Aufrufzahlen
|
||||
|
||||
### Gesamtverteilung im Fenster
|
||||
|
||||
| Portal | PMs | Min | P10 | P25 | **Median** | P75 | P90 | Max | Mittelwert |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| Presseecho | 1436 | 1 | 43 | 72 | **98** | 164 | 212 | 412 | 118,6 |
|
||||
| Businessportal24 | 1491 | 7 | 81 | 127 | **200** | 279 | 351 | 1376 | 214,4 |
|
||||
|
||||
Aufgeteilt nach Bestand:
|
||||
|
||||
| Portal | Bestand | PMs | Median |
|
||||
|---|---|---|---|
|
||||
| Presseecho | seit Relaunch | 1172 | 100 |
|
||||
| Presseecho | Altbestand | 264 | 58 |
|
||||
| Businessportal24 | seit Relaunch | 1210 | 202 |
|
||||
| Businessportal24 | Altbestand | 281 | 189 |
|
||||
|
||||
Häufigkeitsverteilung:
|
||||
|
||||
| Klasse | Presseecho | Businessportal24 |
|
||||
|---|---|---|
|
||||
| 0–24 Aufrufe | 2 % | 0 % |
|
||||
| 25–49 | 11 % | 4 % |
|
||||
| 50–99 | 38 % | 11 % |
|
||||
| 100–199 | 37 % | 35 % |
|
||||
| 200–499 | 12 % | 48 % |
|
||||
| 500–999 | 0 % | 2 % |
|
||||
| 1000+ | 0 % | 0 % |
|
||||
|
||||
### ⚠ Der Gesamtmedian ist NICHT der Basiswert
|
||||
|
||||
`press_releases.hits` ist ein mitlaufender Gesamtzähler, und die Aufrufe wachsen
|
||||
nach der Veröffentlichung über Wochen weiter. Gemessen auf der Dev-Kopie
|
||||
(Stand 13.08.), nur Meldungen seit dem Relaunch:
|
||||
|
||||
| Alter der Meldung | Presseecho ⌀ | Businessportal24 ⌀ |
|
||||
|---|---|---|
|
||||
| 0–6 Tage | 76 | 81 |
|
||||
| 7–13 Tage | 128 | 126 |
|
||||
| 14–20 Tage | 169 | 179 |
|
||||
| 21–27 Tage | 235 | 231 |
|
||||
|
||||
Nach drei Wochen ist **kein Plateau erreicht** — die Kurve steigt weiter. Damit
|
||||
misst der Median über ein Drei-Monats-Fenster nicht, wie viele Aufrufe eine
|
||||
Meldung bekommt, sondern wie viel eine zufällig alte Meldung bisher gesammelt
|
||||
hat. Dieser Wert verschiebt sich mit der Altersmischung des Fensters; zwei
|
||||
Messungen zu verschiedenen Zeitpunkten wären nicht vergleichbar.
|
||||
|
||||
**Vergleichbar ist nur die einzelne Alterskohorte.** Der Report gibt sie seit
|
||||
dem 01.09. mit aus. Als Basiswert festzuhalten ist die Zeile
|
||||
**„28–55 Tage"** je Portal — sie lässt sich in einem halben Jahr identisch
|
||||
erheben.
|
||||
|
||||
> **Offen:** Die Kohortenwerte vom Live-System fehlen noch. Der Report ist
|
||||
> nachgerüstet, aber die Zahlen oben stammen aus dem Lauf davor. Vor dem
|
||||
> Livegang von Block 1 einmal nachziehen:
|
||||
> `php artisan press-releases:view-distribution --json > basiswert-0.1.json`
|
||||
|
||||
---
|
||||
|
||||
## 0.2 Ablehnungsquote je Quelle
|
||||
|
||||
| Quelle | Eingereicht | Veröffentlicht | Abgelehnt | **Quote** |
|
||||
|---|---|---|---|---|
|
||||
| API: connektar.de Presseverteiler (#13993) | 2428 | 2369 | 59 | **2,4 %** |
|
||||
| direkt (Kundenbereich) | 13 | 13 | 0 | 0 % |
|
||||
| **gesamt** | **2441** | **2382** | **59** | **2,4 %** |
|
||||
|
||||
### Nur eine API-Quelle
|
||||
|
||||
Die Roadmap nannte ursprünglich „connektar, openPR, direkt". Das war ein
|
||||
Missverständnis: openPR ist kein zweiter Kanal, sondern der Presse-API-Verteiler
|
||||
von connektar — also derselbe Zulauf. Bestätigt am 01.09.2026.
|
||||
|
||||
In den Daten: genau ein API-Token wurde je benutzt (User 13993). Der Report
|
||||
gruppiert trotzdem generisch je API-Client, damit ein späterer zweiter Kunde
|
||||
automatisch eine eigene Zeile bekommt.
|
||||
|
||||
### ⚠ Was diese Zahl für Block 1d bedeutet
|
||||
|
||||
59 Ablehnungen im Quartal sind eine schmale Grundlage. Grobe Abschätzung bei
|
||||
n ≈ 2400 und p = 2,4 %: Der Standardfehler liegt bei rund 0,3 Prozentpunkten,
|
||||
der eines Vorher-Nachher-Vergleichs bei rund 0,44.
|
||||
|
||||
| Angestrebte Wirkung von 1d | Erkennbar nach einem Quartal? |
|
||||
|---|---|
|
||||
| Quote halbiert (2,4 % → 1,2 %) | ja, rund 2,7 Standardfehler |
|
||||
| Quote um 20 % gesenkt (2,4 % → 1,9 %) | nein, rund 1,1 Standardfehler |
|
||||
|
||||
**Folge für die Abbruchbedingung von Block 1:** „Wiedereinreichung nach
|
||||
Ablehnung ist messbar" als Kriterium arbeitet mit rund 59 Fällen je Quartal.
|
||||
Ein Schwellwert muss dazu passen, sonst wird Block 1 an einem Rauschen
|
||||
gemessen.
|
||||
|
||||
---
|
||||
|
||||
## Was daraus für E2 folgt
|
||||
|
||||
**E2 — Zeigt die Absenderansicht Rohzahlen oder eine Einordnung gegen den
|
||||
Portaldurchschnitt?**
|
||||
|
||||
**Empfehlung: Rohzahlen.** Begründung:
|
||||
|
||||
1. **Kein Bodensatz, den eine Einordnung kaschieren müsste.** Unter 25 Aufrufen
|
||||
liegen 2 % (Presseecho) beziehungsweise 0 % (Businessportal24) der
|
||||
Meldungen. Die Befürchtung aus der Roadmap — „fällt die Verteilung schwach
|
||||
aus, werden Rohzahlen zum Nachteil" — tritt nicht ein.
|
||||
2. **Die Verteilungen sind nahezu symmetrisch.** Mittelwert zu Median: 118,6 zu
|
||||
98 und 214,4 zu 200. Es gibt keine Handvoll Ausreißer, die den Schnitt nach
|
||||
oben zieht und die Mehrheit darunter zurücklässt.
|
||||
3. **Eine Einordnung gegen den Portaldurchschnitt wäre sogar falsch.** Wegen
|
||||
der Wachstumskurve oben vergleicht sie eine zwei Tage alte Meldung gegen
|
||||
einen Schnitt, der drei Monate alte Meldungen enthält — jede frische
|
||||
Veröffentlichung sähe wie ein Misserfolg aus. Eine Einordnung müsste
|
||||
zwingend altersgleich sein, und dann ist sie deutlich aufwendiger als der
|
||||
Nutzen rechtfertigt.
|
||||
4. **Ein portalübergreifender Durchschnitt verbietet sich ohnehin.** Die
|
||||
Mediane unterscheiden sich um den Faktor zwei (98 gegenüber 200). Jede
|
||||
Einordnung müsste je Portal geführt werden.
|
||||
|
||||
Wenn später doch eine Einordnung gewünscht ist, dann als **Perzentil-Angabe
|
||||
innerhalb der eigenen Alterskohorte** („liegt über 60 % der Meldungen
|
||||
vergleichbaren Alters auf diesem Portal") — nicht als Vergleich gegen einen
|
||||
Durchschnitt. Die Kohortenlogik dafür steht bereits im Report.
|
||||
|
|
@ -10,7 +10,7 @@
|
|||
|
||||
Das Portal hat Traffic, aber der Traffic besteht überwiegend aus Lesern fremder Meldungen. Zwischen "Meldung wird gelesen" und "jemand bucht" existiert keine Strecke. Alles, was den Trichter oben verbreitert, wirkt erst, wenn er unten geschlossen ist.
|
||||
|
||||
Der größte ungenutzte Bestand liegt bereits im System: Firmen, deren Meldungen über connektar und openPR eingehen und die kein Konto haben. Sie sind erfasst, ihre Kontaktadresse liegt vor, sie werden bereits transaktional benachrichtigt, und die Magic-Link-Verifikation dieser Adresse ist gebaut.
|
||||
Der größte ungenutzte Bestand liegt bereits im System: Firmen, deren Meldungen über connektar eingehen und die kein Konto haben (openPR ist kein zweiter Kanal, sondern der Presse-API-Verteiler von connektar — derselbe Zulauf). Sie sind erfasst, ihre Kontaktadresse liegt vor, sie werden bereits transaktional benachrichtigt, und die Magic-Link-Verifikation dieser Adresse ist gebaut.
|
||||
|
||||
**Grundsatz für die Reihenfolge:** erst messen, dann den Trichter unten schließen, dann oben verbreitern, dann Partner.
|
||||
|
||||
|
|
@ -37,7 +37,7 @@ Ohne definierte Konversionspunkte ist keiner der folgenden Blöcke bewertbar. Di
|
|||
| Nr. | Arbeitspaket | Aufwand | Abhängigkeit |
|
||||
|---|---|---|---|
|
||||
| 0.1 | Basiswert Aufrufzahlen: Median und Verteilung je Portal über die letzten drei Monate | S | keine |
|
||||
| 0.2 | Basiswert Ablehnungsquote je Quelle (connektar, openPR, direkt), als wiederkehrende Auswertung angelegt | S | keine |
|
||||
| 0.2 | Basiswert Ablehnungsquote je Quelle (API-Client, direkt), als wiederkehrende Auswertung angelegt | S | keine |
|
||||
| 0.3 | Tabelle `conversion_events` anlegen: Ereignistyp, Firmen-ID, Meldungs-ID, Quelle, Zeitstempel | S | keine |
|
||||
| 0.4 | Herkunftsparameter in allen Mail-Links, gespeichert **auf dem Magic-Link-Token**, nicht in der Session | S | 0.3 |
|
||||
| 0.5 | Ereignisse an den bestehenden Zustandsänderungen mitschreiben | M | 0.3, 0.4 |
|
||||
|
|
|
|||
|
|
@ -112,6 +112,43 @@ test('the view distribution command renders without data', function () {
|
|||
$this->artisan('press-releases:view-distribution')->assertSuccessful();
|
||||
});
|
||||
|
||||
test('the view distribution groups releases into age cohorts', function () {
|
||||
/** @var TestCase $this */
|
||||
baselineRelease(Portal::Presseecho, 10, ['published_at' => now()->subDays(2)]);
|
||||
baselineRelease(Portal::Presseecho, 90, ['published_at' => now()->subDays(10)]);
|
||||
baselineRelease(Portal::Presseecho, 300, ['published_at' => now()->subDays(40)]);
|
||||
|
||||
$data = app(ViewDistributionReport::class)->build(now()->subMonths(3));
|
||||
$cohorts = collect(collect($data['portals'])->firstWhere('portal', 'presseecho')['cohorts'])
|
||||
->keyBy('alter');
|
||||
|
||||
// `hits` wächst nach der Veröffentlichung über Wochen weiter. Nur innerhalb
|
||||
// einer Alterskohorte ist der Wert zwischen zwei Messzeitpunkten
|
||||
// vergleichbar — der Gesamtmedian hängt an der Altersmischung des Fensters.
|
||||
expect($cohorts['0–6 Tage']['median'])->toBe(10)
|
||||
->and($cohorts['7–13 Tage']['median'])->toBe(90)
|
||||
->and($cohorts['28–55 Tage']['median'])->toBe(300);
|
||||
});
|
||||
|
||||
test('the age cohorts leave out legacy stock', function () {
|
||||
/** @var TestCase $this */
|
||||
baselineRelease(Portal::Presseecho, 50, ['published_at' => now()->subDays(3)]);
|
||||
baselineRelease(Portal::Presseecho, 5000, [
|
||||
'published_at' => now()->subDays(3),
|
||||
'legacy_portal' => Portal::Presseecho->value,
|
||||
'legacy_id' => 4712,
|
||||
]);
|
||||
|
||||
$data = app(ViewDistributionReport::class)->build(now()->subMonths(3));
|
||||
$cohorts = collect(collect($data['portals'])->firstWhere('portal', 'presseecho')['cohorts'])
|
||||
->keyBy('alter');
|
||||
|
||||
// Die Zähler des Altbestands stammen aus dem Altsystem und haben keine
|
||||
// hier nachvollziehbare Vorgeschichte — sie würden die Kurve verfälschen.
|
||||
expect($cohorts['0–6 Tage']['anzahl'])->toBe(1)
|
||||
->and($cohorts['0–6 Tage']['median'])->toBe(50);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------- 0.2 Ablehnungsquote
|
||||
|
||||
test('the rejection rate is reported per submitting source', function () {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue