mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 20:37:03 +01:00
One row per command attempt, not two aggregates written only on success. command_uses holds last-use only and daily_uses counts successes; both are rate-limiter state and both are written by store.record_use, which the dispatcher calls only when a handler returns cleanly. A gate refusal and a crash returned before it, so a command that failed every time looked exactly like one nobody ran, and nothing knew which command a counted message was. command_events now takes every attempt with its outcome, its duration and the item it served; upstream_calls takes every outbound request made while serving one. The two writes stay separate on purpose -- recording a refusal as a use is what slid the cooldown window forward every time somebody was told to wait -- and test_dispatcher_outcomes asserts last_used does not move. Backfilled from journald joined against message_events, with daily_uses filling the residual so the two sources cannot double-count. Two things were wrong on the first attempt and both were found by measuring rather than by reasoning: the window has to be symmetric, because sent_at_ms comes from the sender's phone and the journal stamp from this host (194 of 299 lines landed *before* their own message, by up to 3.2 s); and matching has to be a global closest-pair assignment, because per-row greedy cannot separate two commands seconds apart. Together those took the dropped rows from 137 to zero, and recovered 418 invocations back to 29 August. Also: !analityka in the group, twelve Polish lines, naming at most three people and only ones whose display name has been seen; an Analytics tab with Nivo charts; and continuous latency measurement, which answers the open question in DOCS-latency.md -- a text send is 235 ms and an image send about 2.2 s, so the upload really is where the time goes. EPHEMERAL_TABLES was doing two unrelated jobs and is now two tuples: what a schema bump rebuilds, and what leaving a group clears. daily_uses left the first (it is a backfill source now) and stayed in the second, and _drop_reshaped took over the repair a bump used to do for it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T4QTp3h5Q3MvfrwGV8xqPf
200 lines
7.0 KiB
Python
200 lines
7.0 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""What !analityka says in the group.
|
|
|
|
Twelve lines is a cap, not a quota: a line with nothing to say is dropped
|
|
rather than printed empty, the same way the nightly report drops its
|
|
highlights. No ``ljust`` anywhere -- Signal renders a proportional font, so
|
|
padding lines up in a terminal and on nobody's phone.
|
|
|
|
Two rules about people, both inherited from ``DOCS-statistics.md``.
|
|
|
|
Only somebody whose display name has actually been seen may be named; the
|
|
group listing knows everyone else as a raw uuid or a phone number, and
|
|
printing either into a chat is a leak. And a per-person breakdown of
|
|
everybody's habits stays on the management page: the group gets a top three
|
|
and the asker's own favourite, which is what was decided when this was
|
|
specified.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from typing import TYPE_CHECKING, Final
|
|
|
|
from signal_bot.analytics import trend as trends
|
|
from signal_bot.stats import text
|
|
from signal_bot.stats.periods import pl_day_month
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Mapping, Sequence
|
|
|
|
from signal_bot.analytics.demand import Demand
|
|
from signal_bot.analytics.hours import Peak
|
|
from signal_bot.analytics.scope import Window
|
|
from signal_bot.analytics.usage import CommandUse, PersonUse
|
|
|
|
MAX_LINES: Final = 12
|
|
TOP_COMMANDS: Final = 3
|
|
TOP_PEOPLE: Final = 3
|
|
|
|
HEADING: Final = "Analityka komend"
|
|
NOTHING_YET: Final = "Nikt jeszcze nie użył żadnej komendy."
|
|
|
|
DIRECTIONS: Final = {
|
|
trends.RISING: "użycie rośnie",
|
|
trends.FALLING: "użycie spada",
|
|
trends.FLAT: "użycie się trzyma",
|
|
}
|
|
|
|
|
|
def days_phrase(count: int) -> str:
|
|
"""A day count with its noun agreeing: 3 dni."""
|
|
return f"{count} {text.plural(count, 'dzień', 'dni', 'dni')}"
|
|
|
|
|
|
def times_phrase(count: int) -> str:
|
|
"""A count with its noun agreeing: 5 razy."""
|
|
return f"{count} {text.plural(count, 'raz', 'razy', 'razy')}"
|
|
|
|
|
|
def window_line(measured: Window) -> str:
|
|
"""What the numbers actually cover. Always printed, always first.
|
|
|
|
The bot is days old, not months. A reader who cannot see that is reading
|
|
a month panel drawn from three days as though it were a month.
|
|
"""
|
|
span = days_phrase(measured.days)
|
|
if measured.first_day == measured.last_day:
|
|
return f"Okno: {span} ({pl_day_month(measured.first_day)}), {times_phrase(measured.events)}"
|
|
return (
|
|
f"Okno: {span} ({pl_day_month(measured.first_day)}"
|
|
f" — {pl_day_month(measured.last_day)}), {times_phrase(measured.events)}"
|
|
)
|
|
|
|
|
|
def top_line(uses: Sequence[CommandUse]) -> str:
|
|
"""The three most used commands, with their counts."""
|
|
best = ", ".join(f"!{use.command} {use.uses}" for use in uses[:TOP_COMMANDS])
|
|
return f"Najczęściej: {best}"
|
|
|
|
|
|
def rarest_line(uses: Sequence[CommandUse]) -> str:
|
|
"""The least used command that anybody has used at all."""
|
|
quietest = uses[-1]
|
|
return f"Najrzadziej: !{quietest.command} — {times_phrase(quietest.uses)}"
|
|
|
|
|
|
def hours_line(peak: Peak) -> str:
|
|
"""When the group actually reaches for the bot."""
|
|
return f"Najgłośniejsza godzina: {peak.busiest:02d}:00 ({times_phrase(peak.busiest_uses)})"
|
|
|
|
|
|
def trend_line(direction: str) -> str:
|
|
"""The verdict, or nothing at all when it is too early to have one.
|
|
|
|
Returning "" rather than "za mało danych" is deliberate: the window line
|
|
above already says how many days there are, and repeating it as an
|
|
apology wastes one of twelve lines.
|
|
"""
|
|
return DIRECTIONS.get(direction, "")
|
|
|
|
|
|
def health_line(uses: Sequence[CommandUse]) -> str:
|
|
"""The command most in need of attention, if any is.
|
|
|
|
Refusals count as much as errors here. A command refused half the time
|
|
is not popular, it is mis-tuned, and from the group's side both read as
|
|
"it does not work".
|
|
"""
|
|
troubled = [use for use in uses if use.errors or use.refused]
|
|
if not troubled:
|
|
return ""
|
|
worst = max(troubled, key=lambda use: (use.errors + use.refused, use.command))
|
|
# "błędów: 3" rather than "3 błędy": a label and a number needs no
|
|
# agreement at all, and Polish agreement after a numeral has three forms
|
|
# whose rule reads the last two digits. Sidestepping it here is free.
|
|
parts = []
|
|
if worst.errors:
|
|
parts.append(f"błędów: {worst.errors}")
|
|
if worst.refused:
|
|
parts.append(f"odmów: {worst.refused}")
|
|
return f"Do poprawki: !{worst.command} ({', '.join(parts)})"
|
|
|
|
|
|
def demand_line(demand: Demand) -> str:
|
|
"""The trigger people keep almost typing, which wants another alias."""
|
|
if not demand.near_misses:
|
|
return ""
|
|
miss = demand.near_misses[0]
|
|
return f"Blisko trafień: {miss.target} — {times_phrase(miss.hits)}"
|
|
|
|
|
|
def people_line(people: Sequence[PersonUse], names: Mapping[str, str]) -> str:
|
|
"""The top three, and only those whose name the bot has actually seen."""
|
|
named = [(names[person.uuid], person.uses) for person in people if person.uuid in names]
|
|
if not named:
|
|
return ""
|
|
best = ", ".join(f"{name} {uses}" for name, uses in named[:TOP_PEOPLE])
|
|
return f"Kto najwięcej: {best}"
|
|
|
|
|
|
def mine_line(people: Sequence[PersonUse], uuid: str) -> str:
|
|
"""The asker's own favourite. Self-scope, so it names nobody else."""
|
|
for person in people:
|
|
if person.uuid == uuid and person.favourite:
|
|
return f"Twoja ulubiona: !{person.favourite} ({times_phrase(person.uses)})"
|
|
return ""
|
|
|
|
|
|
def share_line(commands: int, messages: int) -> str:
|
|
"""How much of the group's talking is talking to the bot."""
|
|
if not messages:
|
|
return ""
|
|
percent = round(100 * commands / messages)
|
|
return f"Komendy to {percent}% wiadomości w tej grupie"
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Summary:
|
|
"""Everything the reply needs, gathered before any of it is worded.
|
|
|
|
One object rather than ten arguments, because these always travel
|
|
together: the command builds it from the query layer and the renderer
|
|
turns it into Polish, and neither half needs to know the other.
|
|
"""
|
|
|
|
measured: Window
|
|
uses: Sequence[CommandUse]
|
|
people: Sequence[PersonUse]
|
|
names: Mapping[str, str]
|
|
peak: Peak
|
|
direction: str
|
|
demand: Demand
|
|
asker: str
|
|
commands: int
|
|
messages: int
|
|
|
|
|
|
def render(summary: Summary) -> str:
|
|
"""Build the reply, capped at twelve lines and never padded."""
|
|
if not summary.uses:
|
|
return NOTHING_YET
|
|
lines = [
|
|
HEADING,
|
|
window_line(summary.measured),
|
|
top_line(summary.uses),
|
|
rarest_line(summary.uses),
|
|
hours_line(summary.peak),
|
|
trend_line(summary.direction),
|
|
share_line(summary.commands, summary.messages),
|
|
health_line(summary.uses),
|
|
demand_line(summary.demand),
|
|
people_line(summary.people, summary.names),
|
|
mine_line(summary.people, summary.asker),
|
|
]
|
|
# Dropped, then capped. A line with nothing to say is not printed empty,
|
|
# so the cap bites on content rather than on blanks -- and the cap is
|
|
# what keeps a reply readable on a phone.
|
|
kept = [line for line in lines if line][:MAX_LINES]
|
|
return "\n".join(kept)
|