signal-bot/signal_bot/stats/highlights.py
Krzysztof kuhy Rudnicki bfc2bcb3b8 feat: nine commands for asking the statistics questions
!staty, !wczoraj, !tydzień, !miesiąc, !rok, !rekordy, !korona, !dni and
!ja, under 29 triggers with both Polish spellings of each.

None takes an argument. registry.resolve matches the whole folded body
against a trigger, so "!ja ala" resolves to nothing; teaching it to
split on whitespace would silently make "!kot pls" fire where today it
does nothing. Each question gets its own trigger instead.

!wczoraj renders through the same code path as the nightly post, which
is not a convenience: the bot cannot trigger its own 21:37 report, so
without byte-identical output there is no way to check the headline
feature of this release short of waiting for the evening.

Two guards worth naming:

- test_every_trigger_is_unique builds the whole registry.
  DuplicateTriggerError subclasses ValueError, and ValueError is in
  startup_retry.RETRYABLE -- so a colliding alias would not crash the
  bot, it would retry forever logging "signal-cli not reachable yet".
  Checked at test time instead of never.
- No trigger may contain a digit: fold() maps 0134578 onto letters, so
  "!7dni" would resolve as "!tdni". Also asserted.

Every command answers on an empty history rather than crashing, which is
the state the group will actually be in for the next few days: records,
the crown and the weekday table all say "za mało danych" because their
only day so far is the backfilled one, and they exclude it.

Verified against a copy of the live database -- all nine render
correctly on the real group's data.

One test bug caught while writing it: it seeded "today" and excluded
2026-08-30, which on the day it was written were the same date, so it
passed for the wrong reason. Fixed dates now.

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

87 lines
3.0 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Choosing the two or three things worth saying about a day.
There are around twenty things the bot could say every night. Saying all of
them is a wall of text nobody reads, and saying whichever three happen to be
computed first is arbitrary. So every candidate declares a priority and a
magnitude, and the picker takes the top few.
Two rules make the result readable rather than merely short:
* **One per family.** "Ala set a personal record", "Ala had 60% of the
day" and "Ala kept the crown" are three sentences about the same thing.
Families collapse them to the strongest.
* **Fully deterministic.** No clock, no randomness, and ties break on the
candidate's own name, so the same day always produces the same post. A
nightly report that reshuffles itself between two renders reads as a bug.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Final
if TYPE_CHECKING:
from collections.abc import Sequence
# Families. Anything in the same one is a restatement of the same fact.
RECORD: Final = "record"
CROWN: Final = "crown"
TREND: Final = "trend"
TIMING: Final = "timing"
VOICE: Final = "voice"
# How many highlight lines a day block has room for. Lives here rather than
# beside either caller, so the nightly post and !wczoraj cannot drift apart.
SLOTS: Final = 3
@dataclass(frozen=True, slots=True)
class Highlight:
"""One thing worth saying, and how much it wants saying."""
kind: str
family: str
priority: int
magnitude: float
text: str
def select(candidates: Sequence[Highlight | None], slots: int) -> list[Highlight]:
"""Pick at most ``slots`` highlights, strongest first, one per family.
Args:
candidates: Every candidate, with None for the ones that did not
fire. Callers pass the whole list rather than filtering, so a
candidate that never fires is still visible in the code.
slots: How many lines the renderer has room for.
Returns:
The chosen highlights, most important first.
"""
fired = sorted(
(candidate for candidate in candidates if candidate is not None),
key=lambda c: (-c.priority, -c.magnitude, c.kind),
)
chosen: list[Highlight] = []
seen: set[str] = set()
for candidate in fired:
if candidate.family in seen:
continue
seen.add(candidate.family)
chosen.append(candidate)
if len(chosen) == slots:
break
return chosen
def ratio(value: float, baseline: float) -> float:
"""How far a value stands from its baseline, as a comparable fraction.
Used as ``magnitude`` so that "double the usual Tuesday" and "three times
the usual message length" can be ranked against each other at all. The
baseline is floored at one so a first-ever measurement does not divide by
zero and does not come out infinitely interesting.
"""
return abs(value - baseline) / max(baseline, 1)