signal-bot/signal_bot/stats/records.py
Krzysztof kuhy Rudnicki 0e0c9ba619 feat: the nightly post notices things
Around twenty things could be said about a day. Saying all of them is a
wall nobody reads; saying whichever three are computed first is
arbitrary. So each candidate declares a priority and a magnitude and the
picker takes the top few, with two rules that make the result readable:
one line per family, so "set a record", "had 60% of the day" and "kept
the crown" do not become three sentences about the same person; and
fully deterministic ordering, so a day renders identically twice.

Nine candidates so far: personal records with what they beat, the
group's busiest day ever, a photo record, crown streaks (naming everyone
when it is shared), a silence broken, a monologue, one person dominating
the day, an unusual showing for that weekday, and a night owl.

Every candidate is (DayFacts) -> Highlight | None with the same
signature. On day one of this release almost all of them return None,
and a shape where "not yet" is the ordinary case is much easier to get
right than one where every guard is bespoke.

Two off-by-ones fixed by writing the sentence out and checking it:

- "odezwał się po 3 dniach ciszy" was counting four. The day somebody
  last spoke is not silent, and neither is the day they came back on, so
  the gap excludes both ends. The function is now named for that.
- A personal record on somebody's first ever day was being announced as
  a record. It is, trivially, and saying so every time is noise.

Verified against the live database, read-only on a copy: the real group
renders correctly at 324 messages, claims no average length for the
backfilled day, and fires no highlights at all -- which is right, since
there is one day of history for them to stand against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom
2026-08-30 19:43:55 +02:00

181 lines
5.4 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""All-time records: busiest days, quietest days, personal bests, silences.
Everything here is derived by query rather than kept in a cache table. Two
sources of truth for one number is how a leaderboard comes to disagree with
itself, and the history is small enough that recomputing is free.
The backfilled day is excluded from every record. It is a 22-hour window
filed as a day, so leaving it in would hand somebody a personal best they
never set -- see ``stats.backfill``.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from signal_bot.stats.periods import shift
if TYPE_CHECKING:
from signal_bot.stats.events import EventStore
PERSON_DAYS = """
SELECT day, COUNT(*) AS messages
FROM message_events
WHERE group_id = ? AND uuid = ? AND day <> ?
GROUP BY day
ORDER BY messages DESC, day ASC
"""
MEDIA_DAYS = """
SELECT day, SUM(media_count) AS media
FROM message_events
WHERE group_id = ? AND day <> ?
GROUP BY day
HAVING media > 0
ORDER BY media DESC, day ASC
"""
@dataclass(frozen=True, slots=True)
class DayRecord:
"""One day and the number that makes it a record."""
day: str
value: int
def busiest_day(days: dict[str, int]) -> DayRecord | None:
"""The day with the most messages, earliest day winning a tie."""
if not days:
return None
day = min(days, key=lambda d: (-days[d], d))
return DayRecord(day, days[day])
def quietest_day(days: dict[str, int]) -> DayRecord | None:
"""The quietest day on which anybody spoke at all.
Days with no messages are absent from the series rather than zero, and
that is the right reading: a day nobody spoke is a silence, which
``longest_silence`` measures. A "quietest day" of zero would just name
whichever day the bot happened to be offline.
"""
if not days:
return None
day = min(days, key=lambda d: (days[d], d))
return DayRecord(day, days[day])
def personal_best(
events: EventStore,
group_id: str,
uuid: str,
*,
exclude: str | None = None,
) -> DayRecord | None:
"""Somebody's busiest day ever, or None if they have never written."""
row = events.connection.execute(PERSON_DAYS, (group_id, uuid, exclude or "")).fetchone()
return DayRecord(row["day"], row["messages"]) if row else None
def previous_best(
events: EventStore,
group_id: str,
uuid: str,
before: str,
*,
exclude: str | None = None,
) -> DayRecord | None:
"""Somebody's best day *excluding* one day, for "poprzedni rekord".
Announcing "rekord życiowy: 76" is worth little without what it beat, and
the day being announced must not be its own previous best.
"""
rows = events.connection.execute(PERSON_DAYS, (group_id, uuid, exclude or "")).fetchall()
for row in rows:
if row["day"] != before:
return DayRecord(row["day"], row["messages"])
return None
def most_media_day(
events: EventStore,
group_id: str,
*,
exclude: str | None = None,
) -> DayRecord | None:
"""The day carrying the most photos and videos."""
row = events.connection.execute(MEDIA_DAYS, (group_id, exclude or "")).fetchone()
return DayRecord(row["day"], row["media"]) if row else None
@dataclass(frozen=True, slots=True)
class Silence:
"""A stretch of days on which nobody wrote anything."""
start: str
end: str
days: int
def longest_silence(days: dict[str, int], until: str) -> Silence | None:
"""The longest run of consecutive silent days, up to and including ``until``.
Walks the calendar rather than the recorded days, because the whole point
is the days that are *missing* from the series. Returns None when the
history is too short to contain a gap.
"""
if not days:
return None
spoke = set(days)
first = min(spoke)
best: Silence | None = None
run_start: str | None = None
day = first
while day <= until:
if day in spoke:
if run_start is not None:
run = _silence(run_start, shift(day, -1))
best = run if best is None or run.days > best.days else best
run_start = None
elif run_start is None:
run_start = day
day = shift(day, 1)
if run_start is not None:
run = _silence(run_start, until)
best = run if best is None or run.days > best.days else best
return best
def _silence(start: str, end: str) -> Silence:
"""Build a Silence, counting both ends."""
length = 1
day = start
while day < end:
day = shift(day, 1)
length += 1
return Silence(start, end, length)
def days_silent_before(days: dict[str, int], uuid_days: dict[str, int], until: str) -> int:
"""Recorded days between somebody's last message and ``until``, exclusive.
Both ends are excluded, which is what makes "odezwał się po 3 dniach
ciszy" true rather than off by one: the day they last spoke was not
silent, and neither is the day they came back on.
Bounded by the group's own recorded days, so somebody who joined last week
is not reported as having been silent for a year, and a stretch the bot
was offline for does not count against them.
"""
if not days:
return 0
if not uuid_days:
return sum(1 for day in days if day < until)
last = max(uuid_days)
return sum(1 for day in days if last < day < until)