signal-bot/signal_bot/analytics/trend.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

188 lines
6.0 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Family B, the calendar half: day series, months, and what is dying.
Day-granular, so every source counts -- a ``daily_uses`` row has no hour but
it does know its day, which is exactly what these questions need.
The direction of a trend is deliberately not always stated. ``Window``
carries the day count and ``TREND_MIN_DAYS`` is the threshold; below it the
series is returned and drawn but ``direction`` stays ``UNKNOWN``. A bot that
is three days old has a novelty curve, not a trend, and an arrow drawn from
three points is a claim the data cannot support.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Final
from signal_bot.stats.periods import shift
if TYPE_CHECKING:
import sqlite3
from collections.abc import Sequence
from signal_bot.analytics.scope import Scope, Window
RISING: Final = "rising"
FALLING: Final = "falling"
FLAT: Final = "flat"
UNKNOWN: Final = "unknown"
# How much the second half must differ from the first to be called a
# direction rather than noise. A tenth: below that, on counts this small,
# one busy evening decides the answer.
TREND_MARGIN: Final = 0.10
# A command not used for this many days is called dying rather than merely
# quiet. Deliberately short, because the whole history is days long; it is
# a relative statement and the panel says so.
DYING_AFTER_DAYS: Final = 3
# A direction needs a before and an after to compare.
MIN_POINTS: Final = 2
BY_DAY_SQL: Final = """
SELECT day, COUNT(*) AS uses, COUNT(DISTINCT user_uuid) AS people
FROM command_events
WHERE (:group IS NULL OR group_id = :group)
AND outcome != 'unknown_word'
GROUP BY day
ORDER BY day
"""
BY_COMMAND_DAY_SQL: Final = """
SELECT command, day, COUNT(*) AS uses
FROM command_events
WHERE (:group IS NULL OR group_id = :group)
AND outcome != 'unknown_word'
GROUP BY command, day
ORDER BY command, day
"""
BY_MONTH_SQL: Final = """
SELECT substr(day, 1, 7) AS month, COUNT(*) AS uses, COUNT(DISTINCT day) AS days
FROM command_events
WHERE (:group IS NULL OR group_id = :group)
AND outcome != 'unknown_word'
GROUP BY month
ORDER BY month
"""
LAST_USED_SQL: Final = """
SELECT command, MAX(day) AS last_day, COUNT(*) AS uses
FROM command_events
WHERE (:group IS NULL OR group_id = :group)
AND outcome = 'ok'
GROUP BY command
ORDER BY last_day, command
"""
@dataclass(frozen=True, slots=True)
class Dying:
"""A command that used to be used and has gone quiet."""
command: str
last_day: str
idle_days: int
uses: int
def by_day(conn: sqlite3.Connection, scope: Scope) -> list[tuple[str, int, int]]:
"""(day, uses, people) for every day anything happened."""
return [
(str(row["day"]), int(row["uses"]), int(row["people"]))
for row in conn.execute(BY_DAY_SQL, scope.params())
]
def dense_days(series: Sequence[tuple[str, int, int]]) -> list[tuple[str, int]]:
"""Fill in the days nothing happened on.
A sparse series draws a line straight over a silent day, which reads as
"steady" when the truth is "nobody used it". Built by stepping the date
rather than by adding 86_400 seconds, because Warsaw days are not all
the same length.
"""
if not series:
return []
filled: list[tuple[str, int]] = []
day = series[0][0]
last = series[-1][0]
counts = {entry[0]: entry[1] for entry in series}
while day <= last:
filled.append((day, counts.get(day, 0)))
day = shift(day, 1)
return filled
def by_command_day(conn: sqlite3.Connection, scope: Scope) -> dict[str, dict[str, int]]:
"""Per-command day series, for the sparklines."""
series: dict[str, dict[str, int]] = {}
for row in conn.execute(BY_COMMAND_DAY_SQL, scope.params()):
series.setdefault(str(row["command"]), {})[str(row["day"])] = int(row["uses"])
return series
def by_month(conn: sqlite3.Connection, scope: Scope) -> list[tuple[str, int, int]]:
"""(month, uses, days seen). The days matter: a partial month is not low."""
return [
(str(row["month"]), int(row["uses"]), int(row["days"]))
for row in conn.execute(BY_MONTH_SQL, scope.params())
]
def direction(series: Sequence[tuple[str, int]], measured: Window) -> str:
"""Whether use is rising, falling, flat -- or too early to say.
Compares the mean of the second half against the first. Not a
least-squares fit: on a dozen points a fit is no more truthful and far
harder to explain in a chat message.
"""
if not measured.enough_for_a_trend or len(series) < MIN_POINTS:
return UNKNOWN
# MIN_POINTS above guarantees half >= 1, so neither slice is empty and
# neither division below is by zero.
half = len(series) // 2
first = [count for _day, count in series[:half]]
second = [count for _day, count in series[half:]]
before = sum(first) / len(first)
after = sum(second) / len(second)
if before == 0:
return RISING if after > 0 else FLAT
change = (after - before) / before
if change > TREND_MARGIN:
return RISING
if change < -TREND_MARGIN:
return FALLING
return FLAT
def dying(conn: sqlite3.Connection, scope: Scope, today: str) -> list[Dying]:
"""Commands that worked, were used, and then stopped being used."""
found: list[Dying] = []
for row in conn.execute(LAST_USED_SQL, scope.params()):
last_day = str(row["last_day"])
idle = _days_between(last_day, today)
if idle >= DYING_AFTER_DAYS:
found.append(
Dying(
command=str(row["command"]),
last_day=last_day,
idle_days=idle,
uses=int(row["uses"]),
)
)
return sorted(found, key=lambda entry: (-entry.idle_days, entry.command))
def _days_between(start: str, end: str) -> int:
"""Whole calendar days from one day to another, never negative."""
days = 0
day = start
while day < end:
day = shift(day, 1)
days += 1
return days