signal-bot/signal_bot/analytics/render.py
Krzysztof kuhy Rudnicki 405541be9b
Some checks are pending
CI / check (push) Waiting to run
CI / web (push) Waiting to run
release: 0.2.6 «Stephanus I» — dodano !analityka
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
2026-08-31 23:19:51 +02:00

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)