mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-11 20:34:06 +01:00
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
181 lines
5.4 KiB
Python
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)
|