signal-bot/signal_bot/stats/curiosities.py
Krzysztof kuhy Rudnicki 40538b89a1 fix: !ja told everybody their favourite hour was noon
Reported from the group: the same "Najczęściej piszesz między 12:00 a
13:00" line came back for Ala and for Jan Nowak, which is too much
of a coincidence to be a statistic.

It was the backfill. The 342 messages carried over from the pre-0.1.0
counter have no timestamps, so the seeded rows got a placeholder hour of
12 -- and favourite_hour was the one query that never excluded the
backfilled day. For every person those synthetic noons outnumbered every
real message they had sent since, so all seven got the same answer. The
genuine hours are 19-21: this group talks in the evening.

The comment above that placeholder said "the day is marked anyway, so
nothing that cares about hours will read these", which is exactly the
kind of reasoning that stops being true the moment somebody adds a
query. So the fix is not another `exclude` argument to remember: the
absent hour is now -1, which no real hour can be, and hour queries
filter `local_hour >= 0`. Forgetting the exclusion can no longer invent
an answer, and night_owl gets the same guard even though its 0..2 window
happened to miss the old placeholder.

Live database migrated with the service stopped and a backup taken: 342
rows moved from noon to unknown. Verified afterwards on the real data --
Ala 19:00, Jan Nowak 19:00, kuhy 20:00, and the two people whose
only messages are backfilled correctly get no hour line at all.

Not released. Sitting at 0.1.1 until kuhy says otherwise.

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

233 lines
7.8 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""The things the bot might notice about a day.
Every candidate has the same signature -- ``(DayFacts) -> Highlight | None`` --
and returns None when it has nothing to say or not enough history to say it.
That uniformity is deliberate: on day one of this release almost all of them
return None, and a shape that makes "not yet" the ordinary case is far easier
to get right than one where each guard is bespoke.
Priorities are set so a record beats a trend, and anything about a person
beats anything about an average.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Final
from signal_bot.stats import facts as day_facts
from signal_bot.stats import records, streaks, text
from signal_bot.stats.highlights import (
CROWN,
RECORD,
TIMING,
TREND,
VOICE,
Highlight,
ratio,
)
from signal_bot.stats.periods import WEEKDAY_PLURAL, pl_day_month
if TYPE_CHECKING:
from signal_bot.stats.facts import DayFacts
TROPHY: Final = "\U0001f3c6"
CROWN_EMOJI: Final = "\U0001f451"
CALENDAR: Final = "\U0001f4c5"
CAMERA: Final = "\U0001f4f7"
OWL: Final = "\U0001f989"
SPEAKER: Final = "\U0001f4e2"
# How different from the usual a day has to be before it is worth a line.
WEEKDAY_THRESHOLD: Final = 0.25
DOMINANT_SHARE: Final = 50
MIN_BASELINE_DAYS: Final = 3
LONG_SILENCE: Final = 3
NIGHT_OWL_HOUR: Final = 2
# A record, a streak or a monologue is only interesting once there is
# something for it to stand against -- one day is not a history.
MIN_DAYS_FOR_A_RECORD: Final = 2
MIN_PHOTOS_FOR_A_RECORD: Final = 2
MIN_STREAK: Final = 2
MIN_MESSAGES_FOR_A_MONOLOGUE: Final = 2
MIN_SPEAKERS_FOR_A_SHARE: Final = 2
def personal_record(facts: DayFacts) -> Highlight | None:
"""Somebody beat their own best day ever."""
for row in facts.speakers:
best = day_facts.personal_record(facts, row.uuid)
if best is None:
continue
previous = records.previous_best(
facts.events, facts.group_id, row.uuid, facts.day, exclude=facts.exclude
)
if previous is None:
continue
beaten = f" (poprzedni {previous.value}, {pl_day_month(previous.day)})"
return Highlight(
kind="personal_record",
family=RECORD,
priority=100,
magnitude=ratio(best.value, previous.value),
text=f"{TROPHY} {row.display_name} {best.value} — rekord życiowy{beaten}",
)
return None
def group_record_day(facts: DayFacts) -> Highlight | None:
"""The group's busiest day ever, and it is today."""
busiest = records.busiest_day(facts.history)
if busiest is None or busiest.day != facts.day or len(facts.history) < MIN_DAYS_FOR_A_RECORD:
return None
others = [n for d, n in facts.history.items() if d != facts.day]
return Highlight(
kind="group_record_day",
family=RECORD,
priority=95,
magnitude=ratio(busiest.value, max(others)),
text=f"{TROPHY} najgadatliwszy dzień w historii: {text.messages(busiest.value)}",
)
def media_record(facts: DayFacts) -> Highlight | None:
"""The most photos the group has ever posted in a day."""
record = records.most_media_day(facts.events, facts.group_id, exclude=facts.exclude)
if record is None or record.day != facts.day or record.value < MIN_PHOTOS_FOR_A_RECORD:
return None
return Highlight(
kind="media_record",
family=RECORD,
priority=90,
magnitude=float(record.value),
text=f"{CAMERA} rekord zdjęć: {record.value} w jeden dzień",
)
def crown_streak(facts: DayFacts) -> Highlight | None:
"""Somebody has held the crown several days running."""
running = [s for s in streaks.current_streaks(facts.winners) if s.last_day == facts.day]
if not running or running[0].length < MIN_STREAK:
return None
best = running[0]
longest = streaks.longest_ever(facts.winners)
record = " — rekord!" if longest and longest.length == best.length else ""
names = " i ".join(s.display_name for s in running if s.length == best.length)
return Highlight(
kind="crown_streak",
family=CROWN,
priority=80,
magnitude=float(best.length),
text=f"{CROWN_EMOJI} {names} z koroną {text.days(best.length)} z rzędu{record}",
)
def silence_broken(facts: DayFacts) -> Highlight | None:
"""Somebody spoke after several days of saying nothing."""
for row in facts.speakers:
theirs = facts.person_days(row.uuid)
before = {d: n for d, n in theirs.items() if d < facts.day}
gap = records.days_silent_before(facts.history, before, facts.day)
if gap >= LONG_SILENCE:
return Highlight(
kind="silence_broken",
family=VOICE,
priority=75,
magnitude=float(gap),
text=f"{SPEAKER} {row.display_name} odezwał się po {text.days(gap)} ciszy",
)
return None
def only_one_spoke(facts: DayFacts) -> Highlight | None:
"""A monologue: exactly one person wrote all day."""
if len(facts.speakers) != 1 or facts.totals.messages < MIN_MESSAGES_FOR_A_MONOLOGUE:
return None
return Highlight(
kind="only_one_spoke",
family=VOICE,
priority=65,
magnitude=float(facts.totals.messages),
text=f"{SPEAKER} cały dzień mówił tylko {facts.speakers[0].display_name}",
)
def dominant_share(facts: DayFacts) -> Highlight | None:
"""One person accounted for most of the day on their own."""
if len(facts.speakers) < MIN_SPEAKERS_FOR_A_SHARE or not facts.totals.messages:
return None
top = facts.speakers[0]
share = round(top.messages / facts.totals.messages * 100)
if share < DOMINANT_SHARE:
return None
return Highlight(
kind="dominant_share",
family=VOICE,
priority=60,
magnitude=share / 100,
text=f"{SPEAKER} {top.display_name} to {share}% dzisiejszych wiadomości",
)
def weekday_average(facts: DayFacts) -> Highlight | None:
"""Today stands well away from what this weekday usually looks like."""
others = facts.same_weekday_before
if len(others) < MIN_BASELINE_DAYS:
return None
mean = sum(others) / len(others)
difference = ratio(facts.totals.messages, mean)
if difference < WEEKDAY_THRESHOLD:
return None
direction = "więcej" if facts.totals.messages > mean else "mniej"
return Highlight(
kind="weekday_average",
family=TREND,
priority=50,
magnitude=difference,
text=(
f"{CALENDAR} {WEEKDAY_PLURAL[facts.weekday]} mają średnio "
f"{round(mean)} — dziś o {round(difference * 100)}% {direction}"
),
)
def night_owl(facts: DayFacts) -> Highlight | None:
"""Somebody was still writing in the small hours."""
row = facts.events.connection.execute(
"""
SELECT display_name, local_hour
FROM message_events
WHERE group_id = ? AND day = ? AND local_hour BETWEEN ? AND ?
AND local_hour >= 0
ORDER BY local_hour DESC, sent_at_ms DESC LIMIT 1
""",
(facts.group_id, facts.day, 0, NIGHT_OWL_HOUR),
).fetchone()
if row is None:
return None
return Highlight(
kind="night_owl",
family=TIMING,
priority=55,
magnitude=float(NIGHT_OWL_HOUR - row["local_hour"] + 1),
text=f"{OWL} {row['display_name']} pisał o {row['local_hour']:02d}:00",
)
ALL = (
personal_record,
group_record_day,
media_record,
crown_streak,
silence_broken,
only_one_spoke,
dominant_share,
weekday_average,
night_owl,
)
def for_day(facts: DayFacts) -> list[Highlight | None]:
"""Run every candidate against a day."""
return [candidate(facts) for candidate in ALL]