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

120 lines
4.3 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""What a question is being asked *about*, and which rows may answer it.
Every query in this package is a complete literal string. None of them is
built by concatenation or by an f-string, which is the only arrangement ruff
S608 accepts and this repository takes no suppressions -- ``forget_group``
spells its DELETEs out for the same reason.
"One group, or all of them" is therefore not a clause that gets appended. It
is written into every query once, as::
WHERE (:group IS NULL OR group_id = :group)
Binding ``:group`` to None makes the first half true and the filter vanish.
So the roll-up and the per-group answer run *the same SQL*, which is what
stops them drifting apart -- two queries written to agree eventually will
not.
The other shared decision here is the source filter. Three classes of row
live in ``command_events``:
* ``live`` -- observed as it happened; the only rows with real durations.
* ``journald`` -- reconstructed from the log, with a real hour taken from
the message it was matched to, but no duration and an outcome that was
inferred rather than seen.
* ``daily_uses`` -- a count with no hour at all, carrying ``UNKNOWN_HOUR``.
Counting all three is right for "how often was this used" and wrong for "at
what hour" or "how slow is it". Those two predicates are named here, once,
so that the exclusion is a shared constant rather than something six modules
each have to remember. ``stats/backfill.py`` records what forgetting it
costs: a synthetic hour reached ``favourite_hour`` and the answer was
quietly wrong.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Final
if TYPE_CHECKING:
import sqlite3
# How long a person may go quiet before their next command starts a new
# session. Used only by burstiness and co-occurrence, the two questions that
# need "in one sitting" to mean anything.
#
# Ten minutes: comfortably longer than the pause between two commands
# somebody is idly firing off, far shorter than the gap between two separate
# times of day they picked their phone up.
SESSION_GAP_MS: Final = 10 * 60 * 1000
# Below this many days, a direction is not stated. The line is still drawn --
# the reader can see it -- but no arrow and no "rośnie"/"spada" verdict,
# because three points of a new bot's novelty curve are not a trend.
TREND_MIN_DAYS: Final = 14
@dataclass(frozen=True, slots=True)
class Scope:
"""One group, or all of them."""
group_id: str | None = None
def params(self, **extra: object) -> dict[str, object]:
"""The bound parameters every query in this package expects."""
return {"group": self.group_id, **extra}
@dataclass(frozen=True, slots=True)
class Window:
"""Which days an answer actually covers, and where its rows came from.
Carried on every response because this bot is days old, not months. A
month panel drawn from three days is not wrong, but a reader who cannot
see that it is three days will read it as though it were a month.
"""
first_day: str = ""
last_day: str = ""
days: int = 0
events: int = 0
live: int = 0
journald: int = 0
daily: int = 0
@property
def enough_for_a_trend(self) -> bool:
"""Whether a direction may be stated rather than merely plotted."""
return self.days >= TREND_MIN_DAYS
WINDOW_SQL: Final = """
SELECT MIN(day) AS first_day,
MAX(day) AS last_day,
COUNT(DISTINCT day) AS days,
COUNT(*) AS events,
SUM(source = 'live') AS live,
SUM(source = 'journald') AS journald,
SUM(source = 'daily_uses') AS daily
FROM command_events
WHERE (:group IS NULL OR group_id = :group)
"""
def window(conn: sqlite3.Connection, scope: Scope) -> Window:
"""Measure what the data actually covers, before anything is drawn."""
row = conn.execute(WINDOW_SQL, scope.params()).fetchone()
if row is None or not row["events"]:
return Window()
return Window(
first_day=str(row["first_day"]),
last_day=str(row["last_day"]),
days=int(row["days"]),
events=int(row["events"]),
live=int(row["live"] or 0),
journald=int(row["journald"] or 0),
daily=int(row["daily"] or 0),
)