mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-11 14:37:32 +01:00
!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
87 lines
3.0 KiB
Python
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)
|