mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:27:20 +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
188 lines
6.0 KiB
Python
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
|