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
147 lines
5.0 KiB
Python
147 lines
5.0 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""Family E, the adoption half: who came back, and what a release did.
|
|
|
|
Three questions that are about the bot rather than about any one command.
|
|
|
|
* **Retention** -- of the people who used it on their first day, how many
|
|
were still using it N days later. On a history this short this is one or
|
|
two points, and the panel says so rather than drawing a curve.
|
|
* **Novelty** -- what a release actually did. ``DOCS-changelog.md`` is the
|
|
only record of when each version shipped and it is the one the maintainer
|
|
actually keeps, so the dates come from there rather than from git tags.
|
|
* **Share of chat** -- what fraction of everything said in the group was a
|
|
command. This is the only query in the package that reads
|
|
``message_events``, and it is the number that says whether the bot is a
|
|
fixture or a curiosity.
|
|
"""
|
|
|
|
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
|
|
|
|
FIRST_DAY_SQL: Final = """
|
|
SELECT user_uuid, MIN(day) AS first_day
|
|
FROM command_events
|
|
WHERE (:group IS NULL OR group_id = :group)
|
|
AND outcome = 'ok'
|
|
GROUP BY user_uuid
|
|
"""
|
|
|
|
ACTIVE_DAYS_SQL: Final = """
|
|
SELECT DISTINCT user_uuid, day
|
|
FROM command_events
|
|
WHERE (:group IS NULL OR group_id = :group)
|
|
AND outcome = 'ok'
|
|
"""
|
|
|
|
SHARE_SQL: Final = """
|
|
SELECT COUNT(*) AS messages, SUM(is_command) AS commands
|
|
FROM message_events
|
|
WHERE (:group IS NULL OR group_id = :group)
|
|
AND local_hour >= 0
|
|
"""
|
|
|
|
NEW_PEOPLE_SQL: Final = """
|
|
SELECT day, COUNT(*) AS people
|
|
FROM (
|
|
SELECT user_uuid, MIN(day) AS day
|
|
FROM command_events
|
|
WHERE (:group IS NULL OR group_id = :group)
|
|
AND outcome = 'ok'
|
|
GROUP BY user_uuid
|
|
)
|
|
GROUP BY day
|
|
ORDER BY day
|
|
"""
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Share:
|
|
"""How much of the group's talking is people talking to the bot."""
|
|
|
|
messages: int = 0
|
|
commands: int = 0
|
|
|
|
@property
|
|
def fraction(self) -> float:
|
|
"""Commands as a share of every counted message."""
|
|
return self.commands / self.messages if self.messages else 0.0
|
|
|
|
|
|
def retention(conn: sqlite3.Connection, scope: Scope, horizon: int) -> list[tuple[int, int, int]]:
|
|
"""(day offset, still active, cohort size) for offsets 0..horizon.
|
|
|
|
Days are stepped as dates, never as 86_400_000 milliseconds: Warsaw days
|
|
are not all the same length and the arithmetic version drifts an hour
|
|
twice a year, which would move somebody between buckets.
|
|
"""
|
|
firsts = {
|
|
str(row["user_uuid"]): str(row["first_day"])
|
|
for row in conn.execute(FIRST_DAY_SQL, scope.params())
|
|
}
|
|
if not firsts:
|
|
return []
|
|
active: set[tuple[str, str]] = {
|
|
(str(row["user_uuid"]), str(row["day"]))
|
|
for row in conn.execute(ACTIVE_DAYS_SQL, scope.params())
|
|
}
|
|
cohort = len(firsts)
|
|
return [
|
|
(
|
|
offset,
|
|
sum(1 for uuid, first in firsts.items() if (uuid, shift(first, offset)) in active),
|
|
cohort,
|
|
)
|
|
for offset in range(horizon + 1)
|
|
]
|
|
|
|
|
|
def newcomers(conn: sqlite3.Connection, scope: Scope) -> list[tuple[str, int]]:
|
|
"""(day, how many people used the bot for the first time)."""
|
|
return [
|
|
(str(row["day"]), int(row["people"]))
|
|
for row in conn.execute(NEW_PEOPLE_SQL, scope.params())
|
|
]
|
|
|
|
|
|
def share_of_chat(conn: sqlite3.Connection, scope: Scope) -> Share:
|
|
"""What fraction of the group's messages were commands.
|
|
|
|
Excludes the synthetic rows seeded from the pre-0.1.0 counter, which
|
|
carry UNKNOWN_HOUR and no is_command flag -- counting them would put a
|
|
whole day's messages in the denominator against no commands at all.
|
|
"""
|
|
# An aggregate SELECT returns one row even against an empty table, with
|
|
# NULLs in it -- so there is no None to guard against, only the NULLs.
|
|
row = conn.execute(SHARE_SQL, scope.params()).fetchone()
|
|
return Share(messages=int(row["messages"] or 0), commands=int(row["commands"] or 0))
|
|
|
|
|
|
def around_releases(
|
|
series: Sequence[tuple[str, int]], releases: Sequence[tuple[str, str]], span: int
|
|
) -> list[tuple[str, str, int, int]]:
|
|
"""(version, day, uses just after, uses just before) for each release.
|
|
|
|
"Just after" is the release day and the ``span`` days following it;
|
|
"just before" is the same number of days ending the day before. Equal
|
|
windows, so the comparison means something even at the edges of a short
|
|
history -- a release on the first recorded day gets a before-window of
|
|
zero and reads as such rather than as a spike.
|
|
"""
|
|
counts = dict(series)
|
|
out: list[tuple[str, str, int, int]] = []
|
|
for version, day in releases:
|
|
after = sum(counts.get(shift(day, offset), 0) for offset in range(span + 1))
|
|
before = sum(counts.get(shift(day, -offset), 0) for offset in range(1, span + 1))
|
|
out.append((version, day, after, before))
|
|
return out
|